Files
OrcaSlicer/tests/compare_analyzer/show_temp_plot.py
denis svinarchuk 6a584c7c79 test: add compare_analyzer tools for G-code slice comparison
Add two standalone Python tools for deep comparison and analysis of .3mf
slicing project files:

- compare_slices.py: comprehensive slice comparison with filament usage,
  nozzle mapping, tool change sequences, prime tower analysis, temperature
  timeline, and automatic critical discrepancy detection
- show_temp_plot.py: interactive HTML temperature timeline plotter for
  visualizing heater profiles during multi-nozzle prints

Both tools use only Python stdlib (no external dependencies).
Primary use case: regression testing H2C carousel purge volumes and
BBS compatibility verification.
2026-07-16 15:22:26 +01:00

1358 lines
56 KiB
Python
Executable File

#!/usr/bin/env python3
"""
H2C Temperature Timeline Comparison Tool.
Generates interactive HTML with temperature plots for analyzing and comparing OrcaSlicer vs BambuStudio G-code.
================================================================================
ARCHITECTURE & MAPPING LOGIC:
================================================================================
- H2C configuration utilizes a dual-extruder layout with 4 Vortek nozzles.
- Physical heaters are mapped as:
- Heater 0: Extruder 2 (physical RIGHT nozzle slot, T0/T2/T3/T4)
- Heater 1: Extruder 1 (physical LEFT nozzle slot, T1)
- The active heater mapping is derived dynamically based on active G-code temperature signals:
- When filament X is active and heater N is heated to printing temperature (>200°C),
we associate filament X with heater N.
================================================================================
EXECUTION & RUN RULES:
================================================================================
1. Single File Analysis:
Generates a temperature profile layout for a single 3MF file.
Usage:
python3 show_temp_plot.py <path_to_file.3mf>
2. Comparison Mode (Two Files):
Renders a side-by-side alignment of temperature panels for both files (e.g. OrcaSlicer vs BambuStudio).
Usage:
python3 show_temp_plot.py <path_to_file1.3mf> <path_to_file2.3mf>
Output:
- The script automatically outputs the compiled interactive HTML report to:
~/Desktop/temp_plot.html
- Opens the HTML report in the system's default web browser immediately.
"""
import sys
import os
import zipfile
import json
import xml.etree.ElementTree as ET
import webbrowser
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
import compare_slices
except ImportError:
sys.path.append("/Users/denn/Develop/3dprint/dehancer lab/H2C_v2/scripts")
import compare_slices
def parse_filaments_and_colors(zip_file):
"""Parse filament colors and types from Metadata/slice_info.config inside 3MF."""
filaments = {}
try:
xml_data = zip_file.read("Metadata/slice_info.config")
root = ET.fromstring(xml_data)
plate = root.find("plate")
if plate is not None:
for fil in plate.findall("filament"):
fid = int(fil.attrib.get("id")) # 1-based in XML
color = fil.attrib.get("color")
ftype = fil.attrib.get("type", "PLA")
filaments[fid - 1] = {"color": color, "type": ftype}
except Exception as e:
print(f"Error parsing filaments: {e}")
return filaments
def parse_nozzle_groups(zip_file):
"""
Parse filament-to-extruder mapping from filament_maps in slice_info.config.
filament_maps = "2 1 2 2 2"{0: 2, 1: 1, 2: 2, 3: 2, 4: 2}
Returns:
extruder_map: {filament_id_0based: extruder_id}
extruder_groups: {extruder_id: [list of filament IDs 0-based]}
"""
extruder_map = {}
try:
xml_data = zip_file.read("Metadata/slice_info.config").decode("utf-8", errors="replace")
root = ET.fromstring(xml_data)
plate = root.find("plate")
if plate is not None:
for meta in plate.findall("metadata"):
if meta.attrib.get("key") == "filament_maps":
raw = meta.attrib.get("value", "").strip()
if raw:
for i, v in enumerate(raw.split()):
extruder_map[i] = int(v)
break
except Exception as e:
print(f" Warning: could not parse filament_maps: {e}")
extruder_groups = {}
for fid, eid in extruder_map.items():
extruder_groups.setdefault(eid, []).append(fid)
return extruder_map, extruder_groups
def determine_heater_to_extruder(track, extruder_map):
# H2C physical mapping: Left (Extruder 1) -> Heater 1, Right (Extruder 2) -> Heater 0
return {1: 1, 2: 0}
def build_timeline_and_interpolate(track, tool_changes, m73_points, total_lines, m400_weights=None):
if m400_weights is None:
m400_weights = {}
m73_points = sorted(list(set([(p[0], p[1]) for p in m73_points])))
# Filter to only keep the first occurrence of each unique remaining time value
filtered_m73 = []
seen_times = set()
for line, r_val in m73_points:
if r_val not in seen_times:
filtered_m73.append((line, r_val))
seen_times.add(r_val)
m73_points = filtered_m73
timeline = []
if m73_points:
max_R = m73_points[0][1]
for _, r in m73_points[:5]:
if r > max_R:
max_R = r
for line, r in m73_points:
elapsed_sec = (max_R - r) * 60
timeline.append((line, elapsed_sec))
if timeline[0][0] > 1:
timeline.insert(0, (1, 0))
if timeline[-1][0] < total_lines:
timeline.append((total_lines, max_R * 60))
else:
timeline = [(1, 0), (total_lines, total_lines * 0.02)]
line_to_time = {}
track_m620 = {t["line"]: t.get("in_m620", False) for t in track}
for k in range(len(timeline) - 1):
line1, time1 = timeline[k]
line2, time2 = timeline[k + 1]
dT = time2 - time1
dL = line2 - line1
if dL <= 0:
continue
weights = []
total_w = 0.0
for l in range(line1, line2 + 1):
is_tc = track_m620.get(l, False)
w = 500.0 if is_tc else 1.0
if l in m400_weights:
w += m400_weights[l] * 50.0
weights.append((l, w))
total_w += w
current_t = time1
if total_w == 0:
total_w = 1.0
for l, w in weights:
line_to_time[l] = current_t
current_t += (w / total_w) * dT
def get_time(line):
l_round = int(round(line))
if l_round in line_to_time:
return line_to_time[l_round]
if line <= timeline[0][0]:
return timeline[0][1]
if line >= timeline[-1][0]:
return timeline[-1][1]
for k in range(len(timeline) - 1):
pt1 = timeline[k]
pt2 = timeline[k + 1]
if pt1[0] <= line <= pt2[0]:
if pt2[0] == pt1[0]:
return pt1[1]
return pt1[1] + (line - pt1[0]) / (pt2[0] - pt1[0]) * (pt2[1] - pt1[1])
return 0
for t in track:
t["time"] = get_time(t["line"])
for tc in tool_changes:
tc["time"] = get_time(tc["line"])
total_duration = timeline[-1][1]
return total_duration, get_time
def parse_file_data(filepath):
if not filepath or not os.path.exists(filepath):
return None
try:
with zipfile.ZipFile(filepath) as z:
filaments = parse_filaments_and_colors(z)
extruder_map, extruder_groups = parse_nozzle_groups(z)
_, total_lines, _, stats = compare_slices.parse_critical_gcode(z)
track_raw = stats["temp_track"]
m73_points = stats.get("m73_points", [])
# Find the start of machine end gcode to cut off non-printing trailing commands (e.g. air filtration wait)
end_gcode_line = total_lines
for name in z.namelist():
if name.endswith('.gcode'):
gcode_text = z.read(name).decode('utf-8', errors='replace')
gcode_lines = gcode_text.split('\n')
for i in range(len(gcode_lines) - 1, -1, -1):
gl = gcode_lines[i]
line_num = i + 1
if ';' in gl and '=' not in gl:
if 'MACHINE_END_GCODE_START' in gl or 'filament end gcode' in gl or 'machine: H2C end' in gl:
end_gcode_line = line_num
break
break
# Keep end_gcode_line but do not trim data arrays to preserve full time duration
pass
# Parse TC, Wipe Tower, Toolchange zones and M400 weights from raw gcode
tc_zones_raw = [] # list of (start_line, end_line)
wipe_zones_raw = [] # list of (start_line, end_line)
toolchange_zones_raw = [] # list of (start_line, end_line)
m400_weights = {}
for name in z.namelist():
if name.endswith('.gcode'):
gcode_text = z.read(name).decode('utf-8', errors='replace')
gcode_lines = gcode_text.split('\n')
nc_start = None
wipe_start = None
tc_block_start = None
for i, gl in enumerate(gcode_lines):
line_num = i + 1
# Parse M400 delay commands
if 'M400' in gl:
import re
m_s = re.search(r'M400\s+S(\d+)', gl)
if m_s:
m400_weights[line_num] = float(m_s.group(1))
else:
m_p = re.search(r'M400\s+P(\d+)', gl)
if m_p:
m400_weights[line_num] = float(m_p.group(1)) * 0.001
# Match both Orca and BBS toolchange start comments
if 'CP TOOLCHANGE START' in gl:
tc_block_start = line_num
elif 'CP TOOLCHANGE END' in gl:
if tc_block_start is not None:
toolchange_zones_raw.append((tc_block_start, line_num))
tc_block_start = None
# Match both Orca (;_NOZZLE_CHANGE_START) and BBS (; NOZZLE_CHANGE_START)
if 'NOZZLE_CHANGE_START' in gl:
nc_start = line_num
elif 'NOZZLE_CHANGE_END' in gl:
if nc_start is not None:
tc_zones_raw.append((nc_start, line_num))
nc_start = None
# Match both Orca (; CP TOOLCHANGE WIPE) and BBS (; CP_TOOLCHANGE_WIPE)
elif 'CP TOOLCHANGE WIPE' in gl or 'CP_TOOLCHANGE_WIPE' in gl:
wipe_start = line_num
elif '; CP TOOLCHANGE END' in gl:
if wipe_start is not None:
wipe_zones_raw.append((wipe_start, line_num))
wipe_start = None
break
# Extract preheat events in raw G-code lines format using dynamic nozzle_map
filament_maps_str = " ".join([str(extruder_map[i]) for i in sorted(extruder_map.keys())])
nozzle_map_for_preheat = compare_slices.get_nozzle_map(filament_maps_str, track_raw)
raw_preheats = compare_slices.analyze_preheat_cooldown_events(track_raw, nozzle_map_for_preheat)
track = []
tool_changes = []
current_tool = 0
for line, active_ext, t0, t1, desc, in_m620_block, printing_started in track_raw:
if desc.startswith("T") and "Active Filament" in desc:
parts = desc.split()
t_name = parts[0]
if t_name[1:].isdigit():
t_val = int(t_name[1:])
if t_val < 60000:
current_tool = t_val
track.append({
"line": line,
"active": current_tool,
"t0": t0,
"t1": t1,
"desc": desc,
"in_m620": in_m620_block,
"printing_started": printing_started
})
if desc.startswith("T") and "Active Filament" in desc:
parts = desc.split()
t_name = parts[0]
t_idx = -1
if t_name[1:].isdigit():
t_idx = int(t_name[1:])
tool_changes.append({
"line": line,
"name": t_name,
"idx": t_idx,
"desc": desc,
})
total_duration, get_time = build_timeline_and_interpolate(track, tool_changes, m73_points, total_lines, m400_weights)
end_gcode_time = get_time(end_gcode_line)
# Convert preheat lines to time (seconds)
preheats = []
for ev in raw_preheats:
if ev["preheat_line"] is not None and ev["tc_line"] is not None:
start_t = get_time(ev["preheat_line"])
end_t = get_time(ev["tc_line"])
preheats.append({
"start_time": start_t,
"end_time": end_t,
"heater": ev["target_ext"], # 0 or 1
"target_temp": ev["preheat_temp"],
"nozzle_num": ev["nozzle_num"]
})
heater_to_ext = determine_heater_to_extruder(track, extruder_map)
nozzle_map = {}
for fid, ext_id in extruder_map.items():
nozzle_map[fid] = heater_to_ext.get(ext_id, 0)
# Convert TC/Wipe zones to time coordinates.
# M73 has 1-minute resolution, so TC zones (which last ~20-30s) often map
# to identical times. When that happens, estimate width from line count ratio.
def zone_to_time(start_line, end_line, min_dur=8.0):
t_start = get_time(start_line)
t_end = get_time(end_line)
actual_dur = t_end - t_start
if actual_dur < min_dur:
# M73 has 1-minute resolution and BBS TC is only ~7 lines,
# so interpolated duration can be <1s. Use line-based estimate.
estimated_dur = max(min_dur, (end_line - start_line) * 0.15)
t_end = t_start + estimated_dur
return {"start_time": t_start, "end_time": t_end}
# Sequence nozzle changes (tc_zones) and wipe tower blocks (wipe_zones) sequentially
# to prevent visual overlaps caused by artificial duration extension.
all_sub_zones = []
for s, e in tc_zones_raw:
all_sub_zones.append({"type": "nc", "start_line": s, "end_line": e})
for s, e in wipe_zones_raw:
all_sub_zones.append({"type": "wipe", "start_line": s, "end_line": e})
all_sub_zones.sort(key=lambda z: z["start_line"])
tc_zones = []
wipe_zones = []
prev_end_time = -1.0
min_dur = 8.0
for zone in all_sub_zones:
s_line = zone["start_line"]
e_line = zone["end_line"]
t_start = get_time(s_line)
t_end = get_time(e_line)
actual_dur = t_end - t_start
dur = actual_dur
if actual_dur < min_dur:
dur = max(min_dur, (e_line - s_line) * 0.15)
if t_start < prev_end_time:
t_start = prev_end_time
t_end = t_start + dur
prev_end_time = t_end
formatted_zone = {"start_time": t_start, "end_time": t_end}
if zone["type"] == "nc":
tc_zones.append(formatted_zone)
else:
wipe_zones.append(formatted_zone)
toolchange_zones = [zone_to_time(s, e) for s, e in toolchange_zones_raw]
slicer_name = "OrcaSlicer" if "orca" in os.path.basename(filepath).lower() else (
"BambuStudio" if "bbl" in os.path.basename(filepath).lower() else "Slicer"
)
print(f" Extruder groups: {extruder_groups}")
print(f" Heater→Extruder: {heater_to_ext}")
for fid, heater in sorted(nozzle_map.items()):
ext_id = extruder_map.get(fid, "?")
fil_info = filaments.get(fid, {})
print(f" T{fid} ({fil_info.get('type','?')} {fil_info.get('color','?')}) → Extruder {ext_id} → Heater {heater}")
cooldown_count = sum(1 for ev in raw_preheats if ev.get("cooldown_temp") is not None)
return {
"filename": os.path.basename(filepath),
"slicer": slicer_name,
"total_lines": total_lines,
"total_duration": total_duration,
"end_gcode_time": end_gcode_time,
"filaments": filaments,
"nozzle_map": nozzle_map,
"extruder_groups": extruder_groups,
"heater_to_ext": heater_to_ext,
"tool_changes": tool_changes,
"preheats": preheats,
"preheat_count": len(preheats),
"cooldown_count": cooldown_count,
"tc_zones": tc_zones,
"wipe_zones": wipe_zones,
"toolchange_zones": toolchange_zones,
"track": track,
}
except Exception as e:
import traceback
print(f"Error parsing {filepath}: {e}")
traceback.print_exc()
return None
def find_desktop_3mf_files():
desktop = os.path.expanduser("~/Desktop")
files = [os.path.join(desktop, f) for f in os.listdir(desktop) if f.endswith(".3mf")]
files = sorted(files, key=os.path.getmtime, reverse=True)
return files
def main():
file1_path = None
file2_path = None
if len(sys.argv) > 1:
file1_path = sys.argv[1]
if len(sys.argv) > 2:
file2_path = sys.argv[2]
if not file1_path:
desktop_files = find_desktop_3mf_files()
if len(desktop_files) >= 2:
orcas = [f for f in desktop_files if "orca" in os.path.basename(f).lower()]
bbls = [f for f in desktop_files if "bbl" in os.path.basename(f).lower() or "bambu" in os.path.basename(f).lower()]
if orcas and bbls:
file1_path = orcas[0]
file2_path = bbls[0]
else:
file1_path = desktop_files[0]
file2_path = desktop_files[1]
elif len(desktop_files) == 1:
file1_path = desktop_files[0]
if not file1_path:
print("Usage: python3 show_temp_plot.py <file1.3mf> [file2.3mf]")
sys.exit(1)
print(f"Loading File 1: {file1_path}")
f1_data = parse_file_data(file1_path)
f2_data = None
if file2_path:
print(f"Loading File 2: {file2_path}")
f2_data = parse_file_data(file2_path)
if not f1_data:
print("Error: Failed to parse first file.")
sys.exit(1)
js_data = {
"is_comparison": f2_data is not None,
"file1": f1_data,
"file2": f2_data,
"total_duration": max(f1_data["total_duration"], f2_data["total_duration"]) if f2_data else f1_data["total_duration"],
}
html_content = HTML_TEMPLATE.replace("%DATA_JSON%", json.dumps(js_data))
output_html = os.path.join(os.path.dirname(file1_path), "temp_plot_v3.html")
with open(output_html, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"Interactive HTML report: {output_html}")
webbrowser.open("file://" + os.path.abspath(output_html))
# H2C hardware fact: heater 0 = RIGHT nozzle, heater 1 = LEFT nozzle
# Panel layout: top panels show heater 0 (RIGHT), bottom panels show heater 1 (LEFT)
HTML_TEMPLATE = r"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>H2C Temperature Timeline</title>
<style>
body {
margin: 0; padding: 0;
background-color: #18181b; color: #f4f4f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
}
#header {
background-color: #27272a; padding: 10px 20px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid #3f3f46; height: 50px;
}
#title { font-size: 15px; font-weight: bold; max-width: 250px; line-height: 1.2; }
#info-panel {
font-size: 12px; color: #a1a1aa; flex-grow: 1; margin-left: 20px;
font-family: monospace; white-space: pre-line; line-height: 1.3;
}
#instructions { font-size: 11px; color: #71717a; text-align: right; line-height: 1.3; }
#canvas-container { width: 100vw; height: calc(100vh - 75px); position: relative; }
canvas { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="header">
<div id="title">H2C Temperature Timeline</div>
<div id="info-panel">Hover over graph for synchronized temperature comparison</div>
<div id="instructions">Scroll: Zoom | Drag: Pan | Double-Click: Reset</div>
</div>
<div id="canvas-container"><canvas id="plotCanvas"></canvas></div>
<script>
const data = %DATA_JSON%;
// Get extruder label: "Ext. N (M fil.)"
function getExtLabel(fileData, extId) {
const fils = (fileData.extruder_groups || {})[String(extId)] || [];
return "Ext. " + extId + " (" + fils.length + " fil.)";
}
// Get heater index for a given extruder ID
function getHeaterForExt(fileData, extId) {
if (!fileData.heater_to_ext) return 0;
return fileData.heater_to_ext[String(extId)] ?? 0;
}
// Get sorted extruder IDs for a file
function getExtruderIds(fileData) {
return Object.keys(fileData.extruder_groups || {}).map(Number).sort();
}
// Invert heater_to_ext: heater index → extruder ID
function getExtIdForHeater(fileData, heater) {
if (!fileData.heater_to_ext) return heater;
for (const [extId, h] of Object.entries(fileData.heater_to_ext)) {
if (h === heater) return parseInt(extId);
}
return heater;
}
// nozzle_map: {filament_id → 0 or 1} (heater index)
function getHeater(fileData, filamentId) {
if (!fileData.nozzle_map) return 0;
let fid = filamentId;
if (fid >= 1000) fid -= 1000;
const key = String(fid);
if (key in fileData.nozzle_map) return fileData.nozzle_map[key];
return 0;
}
function isActiveOnPanel(fileData, tool, panelHeater) {
return getHeater(fileData, tool) === panelHeater;
}
function inNozzleChange(fileData, time) {
if (!fileData.tc_zones) return false;
return fileData.tc_zones.some(z => time >= z.start_time && time <= z.end_time);
}
function inToolchangeZone(fileData, time) {
if (!fileData.toolchange_zones) return false;
return fileData.toolchange_zones.some(z => time >= z.start_time && time <= z.end_time);
}
function inWipeZone(fileData, time) {
if (!fileData.wipe_zones) return false;
return fileData.wipe_zones.some(z => time >= z.start_time && time <= z.end_time);
}
const titleEl = document.getElementById("title");
if (data.is_comparison) {
titleEl.innerText = "H2C Comparison:\n1. " + data.file1.filename + "\n2. " + data.file2.filename;
} else {
titleEl.innerText = "H2C Temperatures:\n" + data.file1.filename;
}
const canvas = document.getElementById("plotCanvas");
const ctx = canvas.getContext("2d");
let width, height, scaleXVal = 1.0, offsetX = 0;
let isDragging = false, startDragX = 0, startOffsetX = 0;
let mouseX = -1, mouseY = -1;
const marginLeft = 170, marginRight = 220, topYStart = 60;
let graphH = 200;
let panels = [];
function getVortekFilaments(fileData) {
// Vortek nozzles = all filaments on Heater 0 (shared swappable nozzle block)
return Object.keys(fileData.filaments).map(Number).sort()
.filter(fid => getHeater(fileData, fid) === 0);
}
function calculateLayout() {
width = window.innerWidth;
height = window.innerHeight - 75;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
const panelColors = ["#3b82f6", "#ec4899", "#60a5fa", "#f472b6"];
const NOZZLE_H = 52; // height of mini nozzle-track panels
const MAIN_GAP = 18; // gap between main extruder panels
const NOZZLE_GAP = 8; // gap between mini nozzle panels
const SECTION_GAP = 28; // gap between main section and nozzle section
panels = [];
const files = [data.file1, data.file2].filter(Boolean);
const allExts = files.flatMap(fd => getExtruderIds(fd).map(extId => ({ fd, extId })));
const allVortek = files.flatMap(fd => getVortekFilaments(fd).map(fid => ({ fd, fid })));
const nMain = allExts.length;
const nNozzle = allVortek.length;
// Height budget: main panels take remaining space after nozzle panels + gaps
const nozzleBudget = nNozzle * NOZZLE_H + Math.max(0, nNozzle - 1) * NOZZLE_GAP + SECTION_GAP;
const mainBudget = height - topYStart - nozzleBudget - Math.max(0, nMain - 1) * MAIN_GAP - 30;
graphH = Math.max(70, Math.floor(mainBudget / Math.max(1, nMain)));
let y = topYStart;
// Main extruder panels
allExts.forEach(({ fd, extId }, i) => {
panels.push({
type: 'main',
yStart: y,
height: graphH,
heater: getHeaterForExt(fd, extId),
extId,
file: fd,
color: panelColors[i % 4]
});
y += graphH + MAIN_GAP;
});
// Section divider then mini nozzle panels
y += SECTION_GAP - MAIN_GAP;
allVortek.forEach(({ fd, fid }, i) => {
const filColor = getFilColor(fd, fid);
const filInfo = fd.filaments[fid] || {};
panels.push({
type: 'nozzle',
yStart: y,
height: NOZZLE_H,
heater: getHeater(fd, fid),
filId: fid,
file: fd,
color: filColor,
label: fd.slicer + " T" + fid + (filInfo.type ? " " + filInfo.type : "")
});
y += NOZZLE_H + NOZZLE_GAP;
});
// Pre-classify toolchange markers
[data.file1, data.file2].forEach(fileData => {
if (!fileData) return;
const filtered = [];
fileData.tool_changes.forEach(tc => {
const fid = tc.idx;
if (fid >= 1000) return;
tc.label = fmtTool(fid) + " " + formatTime(tc.time);
filtered.push(tc);
});
fileData.tool_changes = filtered;
});
}
window.addEventListener("resize", () => { calculateLayout(); draw(); });
function formatTime(s) {
if (s < 0) s = 0;
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60);
if (h > 0) return h + ":" + (m<10?"0":"") + m + ":" + (sec<10?"0":"") + sec;
return m + ":" + (sec<10?"0":"") + sec;
}
function draw() {
ctx.clearRect(0, 0, width, height);
const visibleWidth = width - marginLeft - marginRight;
const plotW = visibleWidth * scaleXVal;
offsetX = Math.max(0, Math.min(offsetX, Math.max(0, plotW - visibleWidth)));
const scaleX = t => marginLeft + (t / data.total_duration) * plotW - offsetX;
// Calculate dynamic visibility of toolchange markers to prevent overlapping
[data.file1, data.file2].forEach(fileData => {
if (!fileData) return;
let last_drawn_x = -9999;
const min_text_gap = 72; // pixels gap to prevent overlap
fileData.tool_changes.forEach(tc => {
const x = scaleX(tc.time);
if (x >= marginLeft && x <= marginLeft + visibleWidth) {
if (x - last_drawn_x < min_text_gap) {
tc.hidden_dynamically = true;
} else {
tc.hidden_dynamically = false;
last_drawn_x = x;
}
} else {
tc.hidden_dynamically = true;
}
});
});
// scaleY now takes per-panel height
const scaleY = (temp, yS, pH) => yS + pH - (temp / 250) * pH;
const tempLevels = [0, 50, 100, 140, 180, 220, 250];
const nozzleTempLevels = [0, 100, 180, 220];
const lastPanel = panels[panels.length - 1];
const bottomY = lastPanel ? lastPanel.yStart + lastPanel.height : height;
// Draw section divider label before nozzle panels
const firstNozzlePanel = panels.find(p => p.type === 'nozzle');
if (firstNozzlePanel) {
const divY = firstNozzlePanel.yStart - 16;
ctx.fillStyle = "#52525b"; ctx.font = "10px system-ui"; ctx.textAlign = "left";
ctx.fillText("▾ Vortek nozzle tracks", marginLeft, divY);
ctx.strokeStyle = "#3f3f46"; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(marginLeft + 130, divY - 3); ctx.lineTo(marginLeft + visibleWidth, divY - 3); ctx.stroke();
ctx.setLineDash([]);
}
// Draw all panels
panels.forEach((p, pIdx) => {
const pH = p.height;
const isNozzle = p.type === 'nozzle';
const levels = isNozzle ? nozzleTempLevels : tempLevels;
ctx.fillStyle = isNozzle ? "#0e0e10" : "#111113";
ctx.fillRect(marginLeft, p.yStart, visibleWidth, pH);
ctx.strokeStyle = isNozzle ? "#2d2d30" : "#27272a";
ctx.lineWidth = isNozzle ? 1 : 1.5;
ctx.strokeRect(marginLeft, p.yStart, visibleWidth, pH);
// End G-code zone (end_gcode_time → total_duration) — dark grey / hatched
if (!isNozzle && p.file.end_gcode_time) {
const xS = scaleX(p.file.end_gcode_time), xE = scaleX(p.file.total_duration);
const l = Math.max(marginLeft, xS), r = Math.min(marginLeft + visibleWidth, xE);
if (l < r) {
ctx.fillStyle = "rgba(75, 85, 99, 0.15)";
ctx.fillRect(l, p.yStart, r - l, pH);
ctx.strokeStyle = "rgba(156, 163, 175, 0.5)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(l, p.yStart);
ctx.lineTo(l, p.yStart + pH);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#9ca3af";
ctx.font = "9px sans-serif";
ctx.fillText("End G-code", l + 6, p.yStart + 12);
}
}
// TC zones (NOZZLE_CHANGE_START → END) — purple, deepest background layer
if (!isNozzle && p.file.tc_zones) {
p.file.tc_zones.forEach(z => {
const xS = scaleX(z.start_time), xE = scaleX(z.end_time);
const l = Math.max(marginLeft, xS), r = Math.min(marginLeft + visibleWidth, xE);
if (l < r) {
ctx.fillStyle = "rgba(147, 51, 234, 0.15)";
ctx.fillRect(l, p.yStart, r - l, pH);
ctx.strokeStyle = "rgba(147, 51, 234, 0.4)";
ctx.lineWidth = 1; ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(l, p.yStart); ctx.lineTo(l, p.yStart + pH);
ctx.moveTo(r, p.yStart); ctx.lineTo(r, p.yStart + pH);
ctx.stroke(); ctx.setLineDash([]);
}
});
}
// Wipe Tower zones (CP TOOLCHANGE WIPE → END) — cyan
if (!isNozzle && p.file.wipe_zones) {
p.file.wipe_zones.forEach(z => {
const xS = scaleX(z.start_time), xE = scaleX(z.end_time);
const l = Math.max(marginLeft, xS), r = Math.min(marginLeft + visibleWidth, xE);
if (l < r) {
ctx.fillStyle = "rgba(6, 182, 212, 0.12)";
ctx.fillRect(l, p.yStart, r - l, pH);
ctx.strokeStyle = "rgba(6, 182, 212, 0.35)";
ctx.lineWidth = 1; ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(l, p.yStart); ctx.lineTo(l, p.yStart + pH);
ctx.moveTo(r, p.yStart); ctx.lineTo(r, p.yStart + pH);
ctx.stroke(); ctx.setLineDash([]);
}
});
}
// Preheat regions (only on main panels)
if (!isNozzle && p.file.preheats) {
p.file.preheats.forEach(ph => {
if (ph.heater === p.heater) {
const xStart = scaleX(ph.start_time);
const xEnd = scaleX(ph.end_time);
const left = Math.max(marginLeft, xStart);
const right = Math.min(marginLeft + visibleWidth, xEnd);
if (left < right) {
ctx.fillStyle = "rgba(245, 158, 11, 0.12)";
ctx.fillRect(left, p.yStart, right - left, pH);
ctx.strokeStyle = "rgba(245, 158, 11, 0.35)";
ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
ctx.beginPath();
if (xStart >= marginLeft && xStart <= marginLeft + visibleWidth) {
ctx.moveTo(xStart, p.yStart); ctx.lineTo(xStart, p.yStart + pH);
}
ctx.stroke(); ctx.setLineDash([]);
}
}
});
}
// Label
ctx.fillStyle = isNozzle ? "#71717a" : "#e4e4e7";
ctx.font = isNozzle ? "10px system-ui" : "bold 11px system-ui, sans-serif";
ctx.textAlign = "left";
const label = isNozzle ? p.label : (p.file.slicer + " Ext " + p.extId);
ctx.fillText(label, 15, p.yStart + pH / 2 + 4);
// Temp grid lines
levels.forEach(t => {
const y = scaleY(t, p.yStart, pH);
if (y < p.yStart || y > p.yStart + pH) return;
ctx.strokeStyle = "#27272a"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.setLineDash([4, 4]); ctx.moveTo(marginLeft, y); ctx.lineTo(marginLeft + visibleWidth, y); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = "#52525b"; ctx.font = "9px system-ui"; ctx.textAlign = "right";
ctx.fillText(t + "°", marginLeft - 5, y + 3);
});
// Time grid (only bottom-most panel gets labels)
for (let i = 0; i <= 6; i++) {
const tv = (i / 6) * data.total_duration, x = scaleX(tv);
if (x >= marginLeft && x <= marginLeft + visibleWidth) {
ctx.strokeStyle = "#27272a"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, p.yStart); ctx.lineTo(x, p.yStart + pH); ctx.stroke();
if (pIdx === panels.length - 1) {
ctx.fillStyle = "#71717a"; ctx.font = "11px system-ui"; ctx.textAlign = "center";
ctx.fillText(formatTime(tv), x, p.yStart + pH + 16);
}
}
}
});
// Toolchange markers (main panels only)
panels.filter(p => p.type === 'main').forEach(p => {
const pH = p.height;
p.file.tool_changes.forEach(tc => {
if (tc.hidden_dynamically) return;
const x = scaleX(tc.time);
if (x >= marginLeft && x <= marginLeft + visibleWidth) {
ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.setLineDash([12, 3, 3, 3]);
ctx.moveTo(x, p.yStart); ctx.lineTo(x, p.yStart + pH); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = "#ffffff"; ctx.font = "bold 9.5px system-ui"; ctx.textAlign = "center";
ctx.fillText(tc.label, x, p.yStart - 5);
}
});
});
// Temperature lines
panels.forEach(p => {
if (p.type === 'nozzle') {
drawNozzlePanel(p, scaleX, scaleY, visibleWidth);
} else {
drawTempLine(p.yStart, p.height, p.heater, p.file, scaleX, scaleY, visibleWidth);
}
});
// Crosshair markers in nozzle panels too
panels.filter(p => p.type === 'nozzle').forEach(p => {
const pH = p.height;
p.file.tool_changes.forEach(tc => {
if (tc.hidden_dynamically) return;
const x = scaleX(tc.time);
if (x >= marginLeft && x <= marginLeft + visibleWidth) {
ctx.strokeStyle = "rgba(255,255,255,0.15)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.setLineDash([4, 4]);
ctx.moveTo(x, p.yStart); ctx.lineTo(x, p.yStart + pH); ctx.stroke(); ctx.setLineDash([]);
}
});
});
drawLegend(visibleWidth);
drawTooltips(scaleX, scaleY, visibleWidth, plotW);
}
// --- Main extruder panel temperature line ---
// Shows single heater temp, colored by active filament.
function drawTempLine(yStart, pH, heater, fileData, scaleX, scaleY, visibleWidth) {
ctx.lineWidth = 2.5; ctx.lineCap = "square"; ctx.lineJoin = "miter";
const lB = marginLeft, rB = marginLeft + visibleWidth;
for (let i = 0; i < fileData.track.length - 1; i++) {
const pt1 = fileData.track[i], pt2 = fileData.track[i+1];
const temp1 = heater === 0 ? pt1.t0 : pt1.t1;
const temp2 = heater === 0 ? pt2.t0 : pt2.t1;
const x1 = scaleX(pt1.time), y1 = scaleY(temp1, yStart, pH);
const x2 = scaleX(pt2.time), y2 = scaleY(temp2, yStart, pH);
if (x2 < lB || x1 > rB) continue;
const in_tc1 = inToolchangeZone(fileData, pt1.time);
const in_wipe1 = inWipeZone(fileData, pt1.time);
const is_printing1 = !in_tc1 || in_wipe1;
const is_end_gcode1 = fileData.end_gcode_time && pt1.time >= fileData.end_gcode_time;
const active = isActiveOnPanel(fileData, pt1.active, heater) && is_printing1 && pt1.printing_started && !is_end_gcode1;
let color = "#4b5563";
if (active && temp1 >= 190) color = getFilColor(fileData, pt1.active);
ctx.strokeStyle = color; ctx.beginPath();
if (!active) {
ctx.setLineDash([6, 4]);
ctx.lineWidth = 1.0;
} else {
ctx.setLineDash([]);
ctx.lineWidth = 2.5;
}
let hx1 = Math.max(x1, lB), hx2 = Math.min(x2, rB);
if (hx1 <= hx2) { ctx.moveTo(hx1, y1); ctx.lineTo(hx2, y1); ctx.stroke(); }
ctx.setLineDash([]);
if (x2 >= lB && x2 <= rB) {
const in_tc2 = inToolchangeZone(fileData, pt2.time);
const in_wipe2 = inWipeZone(fileData, pt2.time);
const is_printing2 = !in_tc2 || in_wipe2;
const is_end_gcode2 = fileData.end_gcode_time && pt2.time >= fileData.end_gcode_time;
const nextActive = isActiveOnPanel(fileData, pt2.active, heater) && is_printing2 && pt2.printing_started && !is_end_gcode2;
let nc = "#4b5563";
if (nextActive && temp2 >= 190) nc = getFilColor(fileData, pt2.active);
ctx.strokeStyle = (temp2 > temp1) ? nc : color;
ctx.lineWidth = nextActive ? 2.5 : 1.0;
ctx.setLineDash(nextActive ? [] : [6, 4]);
ctx.beginPath(); ctx.moveTo(x2, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.setLineDash([]);
}
}
}
// --- Mini nozzle panel: single filament track ---
// Shows:
// - Active period: thick colored line (this nozzle is in heater, printing)
// - Preheat/cooldown transitions: thin gray (temp CHANGING = real thermal event)
// - Flat high-temp while OTHER nozzle prints: SKIPPED (not this nozzle's data)
// - Background: thin 25°C baseline
//
// Key insight: T0/T2/T3/T4 share Heater 0. When another nozzle is printing at 220°C,
// temp_T0 is STABLE (no change). When preheat or cooldown happens, temp_T0 is CHANGING.
// We use this to distinguish "our" thermal events from "other nozzle printing".
//
// key threshold changed from 150 to 190.
function drawNozzlePanel(panel, scaleX, scaleY, visibleWidth) {
const { filId, file: fileData, yStart, height: pH } = panel;
const filHeater = getHeater(fileData, filId);
const filColor = getFilColor(fileData, filId);
const lB = marginLeft, rB = marginLeft + visibleWidth;
const STEPS = 4; // track index steps before/after active period to capture preheat/cooldown
ctx.lineCap = "square"; ctx.lineJoin = "miter";
const track = fileData.track;
// Thin standby baseline at 25°C
const yStandby = scaleY(25, yStart, pH);
ctx.strokeStyle = "#2d2d30"; ctx.lineWidth = 1; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(lB, yStandby); ctx.lineTo(rB, yStandby); ctx.stroke();
// 1. Find active index ranges for this filament
const activeIdxRanges = [];
let aStart = -1;
for (let i = 0; i < track.length; i++) {
const t = filHeater === 0 ? track[i].t0 : track[i].t1;
const in_tc = inToolchangeZone(fileData, track[i].time);
const in_wipe = inWipeZone(fileData, track[i].time);
const is_printing = !in_tc || in_wipe;
const is_end_gcode = fileData.end_gcode_time && track[i].time >= fileData.end_gcode_time;
const on = (track[i].active === filId) && (t >= 190) && is_printing && track[i].printing_started && !is_end_gcode;
if (on && aStart === -1) aStart = i;
else if (!on && aStart !== -1) { activeIdxRanges.push({ s: aStart, e: i - 1 }); aStart = -1; }
}
if (aStart !== -1) activeIdxRanges.push({ s: aStart, e: track.length - 1 });
if (activeIdxRanges.length === 0) return;
const isActiveIdx = (i) => activeIdxRanges.some(r => i >= r.s && i <= r.e);
const inWindow = (i) => activeIdxRanges.some(r => i >= r.s - STEPS && i <= r.e + STEPS);
// 2. Draw segments
for (let i = 0; i < track.length - 1; i++) {
if (!inWindow(i) && !inWindow(i + 1)) continue;
const pt1 = track[i], pt2 = track[i + 1];
const temp1 = filHeater === 0 ? pt1.t0 : pt1.t1;
const temp2 = filHeater === 0 ? pt2.t0 : pt2.t1;
const active = isActiveIdx(i) || isActiveIdx(i + 1);
if (!active) {
// In preheat/cooldown window: only draw when temperature is CHANGING.
// A flat high temp means another nozzle is printing — skip it.
const tempChanging = Math.abs(temp2 - temp1) > 3 || temp1 < 100;
if (!tempChanging) continue;
}
const x1 = scaleX(pt1.time), y1 = scaleY(temp1, yStart, pH);
const x2 = scaleX(pt2.time), y2 = scaleY(temp2, yStart, pH);
if (x2 < lB || x1 > rB) continue;
if (active) {
ctx.lineWidth = 2.0; ctx.strokeStyle = filColor; ctx.setLineDash([]);
} else {
ctx.lineWidth = 1; ctx.strokeStyle = "#52525b"; ctx.setLineDash([3, 2]);
}
ctx.beginPath();
let hx1 = Math.max(x1, lB), hx2 = Math.min(x2, rB);
if (hx1 <= hx2) { ctx.moveTo(hx1, y1); ctx.lineTo(hx2, y1); ctx.stroke(); }
ctx.setLineDash([]);
if (x2 >= lB && x2 <= rB) {
ctx.lineWidth = active ? 2.0 : 1;
ctx.strokeStyle = active ? filColor : "#52525b";
ctx.setLineDash(active ? [] : [3, 2]);
ctx.beginPath(); ctx.moveTo(x2, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.setLineDash([]);
}
}
}
function getFilColor(fileData, tool) {
let fid = tool; if (fid >= 1000) fid -= 1000;
if (fid >= 0 && fileData.filaments[fid]) {
let c = fileData.filaments[fid].color;
if (c.toUpperCase() === "#FFFFFF") c = "#e4e4e7";
return c;
}
return "#3b82f6";
}
function getClosestState(track, timeVal) {
let best = track[0];
for (let i = 0; i < track.length; i++) {
if (track[i].time <= timeVal) best = track[i]; else break;
}
return best;
}
function fmtTool(t) { return "T" + (t >= 1000 ? t - 1000 : t); }
function drawLegend(visibleWidth) {
const lx = marginLeft + visibleWidth + 20;
ctx.fillStyle = "#f4f4f5"; ctx.font = "bold 14px system-ui"; ctx.textAlign = "left";
ctx.fillText("Legend", lx, topYStart + 15);
let y = topYStart + 35;
[data.file1, data.file2].forEach((fd, idx) => {
if (!fd) return;
ctx.fillStyle = "#e4e4e7"; ctx.font = "bold 11px system-ui";
ctx.fillText((idx+1) + ". " + fd.slicer + ":", lx, y); y += 15;
ctx.fillStyle = "#71717a"; ctx.font = "10px system-ui";
const toolchangesCount = (fd.tool_changes || []).length;
const nozzleChangesCount = (fd.tc_zones || []).length;
const preheatsCount = fd.preheat_count || 0;
const cooldownsCount = fd.cooldown_count || 0;
ctx.fillText("T-changes: " + toolchangesCount + " | H2C: " + nozzleChangesCount, lx, y); y += 13;
ctx.fillText("Preheats: " + preheatsCount + " | Cools: " + cooldownsCount, lx, y); y += 16;
ctx.font = "11px system-ui";
Object.keys(fd.filaments).forEach(fid => {
const fil = fd.filaments[fid];
let dc = fil.color; if (dc.toUpperCase() === "#FFFFFF") dc = "#e4e4e7";
const heater = getHeater(fd, parseInt(fid));
const extId = getExtIdForHeater(fd, heater);
const extLbl = getExtLabel(fd, extId);
ctx.fillStyle = dc; ctx.fillRect(lx, y-8, 10, 8);
ctx.fillStyle = "#a1a1aa"; ctx.font = "11px system-ui";
ctx.fillText(fmtTool(fid) + ": " + fil.type + " (" + fil.color.toUpperCase() + ")", lx+16, y);
y += 15;
});
y += 8;
});
y += 5;
ctx.fillStyle = "#e4e4e7"; ctx.font = "bold 11px system-ui";
ctx.fillText("Graph:", lx, y); y += 15;
// Active printing
ctx.strokeStyle = "#e4e4e7"; ctx.lineWidth = 2.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(lx, y-5); ctx.lineTo(lx+18, y-5); ctx.stroke();
ctx.fillStyle = "#a1a1aa"; ctx.font = "11px system-ui";
ctx.fillText("Active (printing)", lx+24, y-1); y += 14;
// Standby
ctx.strokeStyle = "#52525b"; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(lx, y-5); ctx.lineTo(lx+18, y-5); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = "#a1a1aa"; ctx.fillText("Standby / Cooldown", lx+24, y-1); y += 14;
// Preheat zone
ctx.fillStyle = "rgba(245, 158, 11, 0.25)"; ctx.fillRect(lx, y-8, 12, 6);
ctx.fillStyle = "#a1a1aa"; ctx.fillText("Preheat zone", lx+20, y-1); y += 14;
// TC zone
ctx.fillStyle = "rgba(147, 51, 234, 0.35)"; ctx.fillRect(lx, y-8, 12, 6);
ctx.fillStyle = "#a1a1aa"; ctx.fillText("Toolchange", lx+20, y-1); y += 14;
// Wipe tower zone
ctx.fillStyle = "rgba(6, 182, 212, 0.3)"; ctx.fillRect(lx, y-8, 12, 6);
ctx.fillStyle = "#a1a1aa"; ctx.fillText("Wipe Tower", lx+20, y-1); y += 14;
// Nozzle mini panels note
ctx.fillStyle = "#52525b"; ctx.font = "10px system-ui";
ctx.fillText("↓ Mini panels = Vortek", lx, y); y += 12;
ctx.fillText(" nozzle tracks", lx, y);
}
function drawTooltips(scaleX, scaleY, visibleWidth, plotW) {
const last = panels[panels.length - 1];
const lastBottom = last ? last.yStart + last.height : height;
if (mouseX < marginLeft || mouseX > marginLeft+visibleWidth || mouseY < topYStart || mouseY > lastBottom) return;
ctx.strokeStyle = "#e4e4e7"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.setLineDash([2,2]); ctx.moveTo(mouseX, topYStart); ctx.lineTo(mouseX, lastBottom); ctx.stroke(); ctx.setLineDash([]);
const timeNum = ((mouseX - marginLeft + offsetX) / plotW) * data.total_duration;
updateInfoPanel(timeNum);
const tips = [];
// 1. Always show the active printing tooltip for both files (main panels only)
[data.file1, data.file2].forEach(fd => {
if (!fd) return;
const st = getClosestState(fd.track, timeNum);
const h = getHeater(fd, st.active);
const p = panels.find(pan => pan.type === 'main' && pan.file === fd && pan.heater === h);
if (p) tips.push({ panel: p, type: "active", state: st });
});
// 2. If hovering a preheat region, add a tiny preheat tooltip
const hoveredPanel = panels.find(p => mouseY >= p.yStart && mouseY <= p.yStart + p.height);
if (hoveredPanel) {
const fd = hoveredPanel.file;
let ph = null;
if (fd.preheats) {
ph = fd.preheats.find(h =>
h.heater === hoveredPanel.heater &&
timeNum >= h.start_time &&
timeNum <= h.end_time
);
}
if (ph) {
const isActive = tips.some(t => t.panel === hoveredPanel && t.type === "active");
if (!isActive) {
tips.push({ panel: hoveredPanel, type: "preheat", info: ph });
}
}
// 3. TC zone tooltip
if (fd.tc_zones) {
const tcz = fd.tc_zones.find(z => timeNum >= z.start_time && timeNum <= z.end_time);
if (tcz) tips.push({ panel: hoveredPanel, type: "tc_zone", info: tcz });
}
// 4. Wipe tower zone tooltip
if (fd.wipe_zones) {
const wz = fd.wipe_zones.find(z => timeNum >= z.start_time && timeNum <= z.end_time);
if (wz) tips.push({ panel: hoveredPanel, type: "wipe_zone", info: wz });
}
}
if (tips.length === 0) return;
// Calculate total height of the stack to center it vertically around mouseY
let totalHeight = 0;
const gap = 8;
tips.forEach(item => {
if (item.type === "tc_zone" || item.type === "wipe_zone") {
totalHeight += 20 + gap;
} else if (item.type === "preheat") {
totalHeight += 22 + gap;
} else {
totalHeight += 108 + gap;
}
});
totalHeight -= gap; // remove last gap
let startY = mouseY - totalHeight / 2;
startY = Math.max(topYStart + 10, Math.min(startY, height - totalHeight - 15));
// Determine X position (left or right of cursor)
const maxTooltipW = 220;
let ttX = mouseX + 15;
if (ttX + maxTooltipW > width) {
ttX = mouseX - maxTooltipW - 15;
}
let currentY = startY;
tips.forEach(item => {
const p = item.panel;
if (item.type === "tc_zone" || item.type === "wipe_zone") {
const isTc = item.type === "tc_zone";
const label = isTc ? "🔧 Toolchange" : "🏗️ Wipe Tower";
const color = isTc ? "rgba(147, 51, 234, 0.9)" : "rgba(6, 182, 212, 0.9)";
const ttW = isTc ? 100 : 95, ttH = 20;
// Center smaller tooltips within the horizontal block
let itemX = ttX;
if (ttX < mouseX) { // aligned left
itemX = mouseX - ttW - 15;
}
ctx.fillStyle = "rgba(17,17,19,0.9)";
ctx.strokeStyle = color; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(itemX, currentY, ttW, ttH, 3); ctx.fill(); ctx.stroke();
ctx.textAlign = "center";
ctx.fillStyle = "#ffffff"; ctx.font = "bold 9px system-ui";
ctx.fillText(label, itemX + ttW/2, currentY + 14);
currentY += ttH + gap;
}
else if (item.type === "preheat") {
const ph = item.info;
const state = getClosestState(p.file.track, timeNum);
const temp = p.heater === 0 ? state.t0 : state.t1;
const pH = p.height;
const yPos = scaleY(temp, p.yStart, pH);
const ttW = 75, ttH = 22;
let itemX = ttX;
if (ttX < mouseX) { // aligned left
itemX = mouseX - ttW - 15;
}
ctx.fillStyle = "rgba(17,17,19,0.95)";
ctx.strokeStyle = "#f59e0b"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.roundRect(itemX, currentY, ttW, ttH, 4); ctx.fill(); ctx.stroke();
ctx.textAlign = "center";
ctx.fillStyle = "#ffffff"; ctx.font = "bold 10px system-ui, sans-serif";
ctx.fillText(fmtTool(ph.nozzle_num) + ": " + temp + "°C", itemX + ttW/2, currentY + 15);
ctx.fillStyle = "#f59e0b"; ctx.beginPath(); ctx.arc(mouseX, yPos, 4, 0, Math.PI*2); ctx.fill();
currentY += ttH + gap;
} else {
const state = item.state;
const pH = p.height;
const temp = p.heater === 0 ? state.t0 : state.t1;
let fIdx = state.active; if (fIdx >= 1000) fIdx -= 1000;
let filColor = "#FFFFFF", filType = "PLA";
if (fIdx >= 0 && p.file.filaments[fIdx]) {
filColor = p.file.filaments[fIdx].color;
filType = p.file.filaments[fIdx].type;
}
const activeOnThisPanel = isActiveOnPanel(p.file, state.active, p.heater);
const yPos = scaleY(temp, p.yStart, pH);
const ttW = 220, ttH = 108;
let sc = activeOnThisPanel ? filColor : "#4b5563";
if (sc.toUpperCase() === "#FFFFFF") sc = "#e4e4e7";
ctx.fillStyle = "rgba(17,17,19,0.95)";
ctx.strokeStyle = sc; ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(ttX, currentY, ttW, ttH, 6); ctx.fill(); ctx.stroke();
ctx.textAlign = "left";
ctx.fillStyle = "#ffffff"; ctx.font = "bold 11px system-ui";
ctx.fillText(p.file.slicer + " — Ext. " + p.extId, ttX+12, currentY+18);
ctx.strokeStyle="#27272a"; ctx.lineWidth=1; ctx.beginPath(); ctx.moveTo(ttX+10,currentY+25); ctx.lineTo(ttX+ttW-10,currentY+25); ctx.stroke();
ctx.font = "11px system-ui"; ctx.fillStyle = "#a1a1aa";
ctx.fillText("Time: " + formatTime(timeNum) + " (Line " + state.line.toLocaleString() + ")", ttX+12, currentY+41);
ctx.fillText("Temp:", ttX+12, currentY+57);
ctx.fillStyle="#ffffff"; ctx.font="bold 11px system-ui";
ctx.fillText(temp + "°C", ttX+100, currentY+57);
let activeText = fmtTool(state.active);
let isEndGCode = p.file.end_gcode_time && timeNum >= p.file.end_gcode_time;
if (isEndGCode) {
activeText = "End G-code";
}
ctx.font="11px system-ui"; ctx.fillStyle="#a1a1aa";
ctx.fillText("Active:", ttX+12, currentY+73);
ctx.fillStyle="#ffffff"; ctx.font="bold 11px system-ui";
ctx.fillText(activeText, ttX+100, currentY+73);
ctx.font="11px system-ui"; ctx.fillStyle="#a1a1aa";
ctx.fillText("Filament:", ttX+12, currentY+89);
let dc = filColor;
if (!activeOnThisPanel || isEndGCode) dc = "#4b5563";
else if (dc.toUpperCase()==="#FFFFFF") dc = "#e4e4e7";
ctx.fillStyle = dc; ctx.fillRect(ttX+100, currentY+81, 8, 8);
ctx.fillStyle="#ffffff"; ctx.font="bold 11px system-ui";
if (isEndGCode) ctx.fillText("N/A", ttX+114, currentY+89);
else if (!activeOnThisPanel) ctx.fillText("Idle", ttX+114, currentY+89);
else ctx.fillText(filType + " (" + filColor.toUpperCase() + ")", ttX+114, currentY+89);
ctx.fillStyle = sc; ctx.beginPath(); ctx.arc(mouseX, yPos, 5, 0, Math.PI*2); ctx.fill();
currentY += ttH + gap;
}
});
}
function updateInfoPanel(timeNum) {
const el = document.getElementById("info-panel");
let txt = "Time: " + formatTime(timeNum);
[data.file1, data.file2].forEach((fd, idx) => {
if (!fd) return;
const st = getClosestState(fd.track, timeNum);
let fIdx = st.active; if (fIdx >= 1000) fIdx -= 1000;
let fType = "PLA", fHex = "#FFFFFF";
if (fIdx >= 0 && fd.filaments[fIdx]) { fHex = fd.filaments[fIdx].color.toUpperCase(); fType = fd.filaments[fIdx].type; }
const htr = getHeater(fd, st.active);
const eId = getExtIdForHeater(fd, htr);
const eLbl = getExtLabel(fd, eId);
const totalTimeStr = formatTime(fd.total_duration);
const printTimeStr = formatTime(fd.end_gcode_time || fd.total_duration);
txt += "\n" + (idx+1) + ". " + fd.slicer + " (Total: " + totalTimeStr + ", Print: " + printTimeStr + "): T0=" + st.t0 + "°C T1=" + st.t1 + "°C [" + eLbl + ", " + fmtTool(st.active) + ": " + fType + " (" + fHex + ")]";
});
el.innerText = txt; el.style.color = "#e4e4e7";
}
// Events
canvas.addEventListener("mousedown", e => {
const r = canvas.getBoundingClientRect(), mx = e.clientX - r.left;
if (mx >= marginLeft && mx <= width - marginRight) { isDragging = true; startDragX = mx; startOffsetX = offsetX; }
});
window.addEventListener("mouseup", () => { isDragging = false; });
canvas.addEventListener("mousemove", e => {
const r = canvas.getBoundingClientRect(); mouseX = e.clientX - r.left; mouseY = e.clientY - r.top;
if (isDragging) offsetX = startOffsetX - (mouseX - startDragX);
draw();
});
canvas.addEventListener("wheel", e => {
e.preventDefault();
const r = canvas.getBoundingClientRect(), mx = e.clientX - r.left;
if (mx < marginLeft || mx > width - marginRight) return;
const vw = width - marginLeft - marginRight;
const tuc = ((mx - marginLeft + offsetX) / (vw * scaleXVal)) * data.total_duration;
scaleXVal = Math.max(1.0, Math.min(scaleXVal * (e.deltaY < 0 ? 1.25 : 0.8), 100.0));
offsetX = (tuc / data.total_duration) * (vw * scaleXVal) - (mx - marginLeft);
draw();
}, { passive: false });
canvas.addEventListener("dblclick", () => { scaleXVal = 1.0; offsetX = 0; draw(); });
calculateLayout();
draw();
</script>
</body>
</html>
"""
if __name__ == "__main__":
main()