<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>M-Bus Parser (Wired & Wireless)</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.0/styles/default.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.0/languages/json.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #ffffff;
}
.header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
margin-bottom: 20px;
max-width: 1100px;
margin-left: auto;
margin-right: auto;
}
.header-text {
text-align: left;
margin-left: 16px;
}
.header-controls {
display: flex;
gap: 10px;
margin-top: 10px;
margin-left: 0;
}
.header-text h1 {
margin: 0;
font-size: 2em;
color: #333;
}
.header-text .subtitle {
font-size: 0.9em;
color: #666;
margin-top: 4px;
}
.header-text .subtitle a {
color: #666;
text-decoration: none;
}
.parser-version {
font-size: 0.7em;
color: #888;
font-weight: normal;
margin-left: 8px;
}
form {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
max-width: 1100px;
margin: 0 auto;
}
label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
margin-bottom: 10px;
font-family: monospace;
}
input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
margin-bottom: 10px;
font-family: monospace;
}
.key-input-container {
position: relative;
margin-bottom: 15px;
}
.key-help {
font-size: 0.85em;
color: #666;
margin-top: 4px;
margin-bottom: 8px;
}
.key-toggle {
position: absolute;
right: 10px;
top: 10px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: #666;
}
.key-toggle:hover {
color: #333;
}
input[type="button"] {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
input[type="button"]:hover {
background-color: #45a049;
}
pre {
background: #f0f0f0;
padding: 10px;
border-radius: 4px;
white-space: pre-wrap;
word-wrap: break-word;
}
#output {
margin-top: 20px;
max-width: 1100px;
margin: 20px auto 0;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background: #f0f0f0;
color: #222;
}
#output-container {
position: relative;
max-width: 1100px;
margin: 20px auto 0;
background: #fff;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
#copy_output {
position: absolute;
top: 10px;
right: 45px;
background: #fff;
border: 1px solid #ccc;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
cursor: pointer;
padding: 8px;
z-index: 10;
transition: background 0.2s, box-shadow 0.2s;
}
#download_output {
position: absolute;
top: 10px;
right: 10px;
background: #fff;
border: 1px solid #ccc;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
cursor: pointer;
padding: 8px;
z-index: 10;
transition: background 0.2s, box-shadow 0.2s;
}
#copy_output:hover,
#download_output:hover {
background: #f0f0f0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
#copy_output svg,
#download_output svg {
display: block;
}
body.dark-mode #share_button svg {
stroke: #aaa;
}
body.dark-mode {
background-color: #181a1b;
color: #e0e0e0;
}
body.dark-mode .header-text h1 {
color: #e0e0e0;
}
body.dark-mode .header-text .subtitle,
body.dark-mode .parser-version {
color: #aaa;
}
body.dark-mode form {
background: #23272a;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
}
body.dark-mode #output-container {
background: #23272a;
border-radius: 8px;
}
body.dark-mode textarea {
background: #23272a;
color: #e0e0e0;
border: 1px solid #444;
}
body.dark-mode input[type="text"] {
background: #23272a;
color: #e0e0e0;
border: 1px solid #444;
}
body.dark-mode .key-help {
color: #aaa;
}
body.dark-mode .key-toggle {
color: #aaa;
}
body.dark-mode .key-toggle:hover {
color: #e0e0e0;
}
body.dark-mode pre {
background: #23272a;
color: #e0e0e0;
}
body.dark-mode #output {
background: #23272a;
color: #e0e0e0;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
}
body.dark-mode #copy_output,
body.dark-mode #download_output {
background: #23272a;
border: 1px solid #444;
}
body.dark-mode #copy_output:hover,
body.dark-mode #download_output:hover {
background: #181a1b;
}
body.dark-mode input[type="button"] {
background-color: #444;
color: #e0e0e0;
}
body.dark-mode input[type="button"]:hover {
background-color: #333;
}
body.dark-mode pre code,
body.dark-mode .hljs {
background: #23272a !important;
color: #e0e0e0 !important;
}
body.dark-mode .hljs-keyword,
body.dark-mode .hljs-selector-tag,
body.dark-mode .hljs-literal,
body.dark-mode .hljs-section,
body.dark-mode .hljs-link {
color: #ffcb6b !important;
}
body.dark-mode .hljs-string,
body.dark-mode .hljs-title,
body.dark-mode .hljs-name,
body.dark-mode .hljs-type,
body.dark-mode .hljs-attribute,
body.dark-mode .hljs-symbol,
body.dark-mode .hljs-bullet,
body.dark-mode .hljs-addition {
color: #c3e88d !important;
}
body.dark-mode .hljs-comment,
body.dark-mode .hljs-quote,
body.dark-mode .hljs-deletion {
color: #616161 !important;
}
body.dark-mode .hljs-meta {
color: #82aaff !important;
}
.hex-viewer {
display: flex;
gap: 0;
font-family: 'Menlo', 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
min-height: 200px;
}
.hex-viewer-left {
flex: 1;
min-width: 0;
overflow-x: auto;
padding: 40px 12px 12px;
}
.hex-viewer-right {
width: 320px;
min-width: 260px;
border-left: 1px solid #ddd;
overflow-y: auto;
max-height: 600px;
padding: 40px 0 8px;
}
body.dark-mode .hex-viewer-right {
border-left-color: #444;
}
.hex-header {
color: #888;
margin-bottom: 4px;
white-space: pre;
user-select: none;
}
.hex-row {
white-space: pre;
margin: 0;
padding: 0;
}
.hex-offset {
color: #888;
user-select: none;
}
.hex-byte {
display: inline-block;
width: 26px;
text-align: center;
cursor: pointer;
border-radius: 2px;
transition: outline 0.1s;
}
.hex-byte:hover {
outline: 2px solid #666;
outline-offset: -1px;
}
.hex-gap {
display: inline-block;
width: 8px;
}
.hex-ascii-sep {
display: inline-block;
width: 16px;
}
.hex-ascii {
display: inline-block;
width: 9px;
text-align: center;
cursor: pointer;
border-radius: 2px;
}
.hex-byte.highlight, .hex-ascii.highlight {
outline: 2px solid #1565c0 !important;
outline-offset: -1px;
}
body.dark-mode .hex-byte.highlight, body.dark-mode .hex-ascii.highlight {
outline-color: #64b5f6 !important;
}
.hex-layer-frame { background: #bbdefb; color: #0d47a1; }
.hex-layer-appheader { background: #c8e6c9; color: #1b5e20; }
.hex-layer-record { background: #fff9c4; color: #f57f17; }
body.dark-mode .hex-layer-frame { background: #1a3a5c; color: #90caf9; }
body.dark-mode .hex-layer-appheader { background: #1b3d1f; color: #a5d6a7; }
body.dark-mode .hex-layer-record { background: #3e3510; color: #fff176; }
.hex-tooltip {
position: fixed;
background: #333;
color: #fff;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
max-width: 350px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
display: none;
}
.hex-tree-section {
padding: 0;
margin: 0;
}
.hex-tree-layer {
font-weight: bold;
font-size: 12px;
padding: 6px 12px 4px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.5px;
user-select: none;
}
body.dark-mode .hex-tree-layer {
color: #aaa;
}
.hex-tree-group {
margin: 0;
padding: 0;
}
.hex-tree-group-header {
padding: 4px 12px;
cursor: pointer;
font-weight: 600;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
user-select: none;
border-radius: 4px;
margin: 0 4px;
}
.hex-tree-group-header:hover {
background: #e3f2fd;
}
body.dark-mode .hex-tree-group-header:hover {
background: #1a3a5c;
}
.hex-tree-group-header .arrow {
display: inline-block;
transition: transform 0.15s;
font-size: 10px;
width: 12px;
}
.hex-tree-group-header.collapsed .arrow {
transform: rotate(-90deg);
}
.hex-tree-group-body {
padding: 0;
margin: 0;
}
.hex-tree-group-body.hidden {
display: none;
}
.hex-tree-item {
padding: 2px 12px 2px 30px;
cursor: pointer;
font-size: 12px;
border-radius: 4px;
margin: 0 4px;
display: flex;
justify-content: space-between;
gap: 8px;
}
.hex-tree-item:hover,
.hex-tree-item.active {
background: #e3f2fd;
}
body.dark-mode .hex-tree-item:hover,
body.dark-mode .hex-tree-item.active {
background: #1a3a5c;
}
.hex-tree-item .tree-kind {
color: #666;
flex-shrink: 0;
}
.hex-tree-item .tree-detail {
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dark-mode .hex-tree-item .tree-kind {
color: #aaa;
}
body.dark-mode .hex-tree-item .tree-detail {
color: #e0e0e0;
}
.hex-tree-item .tree-offset {
color: #999;
font-size: 11px;
flex-shrink: 0;
}
@media (max-width: 800px) {
.hex-viewer {
flex-direction: column;
}
.hex-viewer-right {
width: 100%;
border-left: none;
border-top: 1px solid #ddd;
max-height: 400px;
}
}
</style>
</head>
<body>
<div class="header">
<img id="banner" src="meter.png" alt="Meter Banner">
<div class="header-text">
<h1>Online M-Bus Parser <span class="parser-version" id="parser-version"></span></h1>
<div class="subtitle">
<a href="https://maebli.github.io/m-bus-parser" target="_blank">maebli.github.io/m-bus-parser</a> • <a href="https://github.com/maebli" target="_blank">Michael Aebli</a>
</div>
</div>
<div class="header-controls">
<button id="dark_mode_toggle" title="Toggle dark mode" style="background:none;border:none;cursor:pointer;padding:8px;">
<svg id="dark_mode_icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</button>
<button id="share_button" title="Copy share link" style="background:none;border:none;cursor:pointer;padding:8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
<button id="report_issue" title="Report Issue" style="background:none;border:none;cursor:pointer;padding:8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#e74c3c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<circle cx="12" cy="16" r="1" />
</svg>
</button>
</div>
</div>
<form>
<label for="inputstring">Input String:</label>
<textarea rows="5" cols="80"
id="inputstring">68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16</textarea>
<label for="aeskey">AES-128 Decryption Key (optional):</label>
<div class="key-input-container">
<input type="password" id="aeskey" placeholder="Enter 32 hex characters (16 bytes)" maxlength="32" pattern="[0-9A-Fa-f]{32}" />
<button type="button" class="key-toggle" id="key-toggle" title="Show/Hide key">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
<div class="key-help">Leave empty for unencrypted frames. Format: 32 hexadecimal characters (e.g., 0123456789ABCDEF0123456789ABCDEF)</div>
<input id="parse_json" type="button" value="Parse to JSON" />
<input id="parse_yaml" type="button" value="Parse to YAML" />
<input id="parse_table" type="button" value="Parse to Table" />
<input id="parse_csv" type="button" value="Parse to CSV" />
<input id="parse_mermaid" type="button" value="Parse to Diagram" />
<input id="parse_hexview" type="button" value="Hex View" />
</form>
<div id="output-container">
<button id="copy_output" title="Copy Output">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1-2 2v1" />
</svg>
</button>
<a id="download_output" title="Download Output" href="#" download="">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
<pre id="output"></pre>
</div>
<script type="module">
import init, { m_bus_parse, m_bus_parse_with_key, version } from "./m_bus_parser_wasm_pack.js";
let currentFormat = "json";
mermaid.initialize({ startOnLoad: false, theme: 'default' });
async function setup() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedMode = localStorage.getItem('darkMode');
const body = document.body;
const icon = document.getElementById('dark_mode_icon');
function setDarkMode(on) {
if (on) {
body.classList.add('dark-mode');
icon.innerHTML = '<path d="M21.64 13.64A9 9 0 1 1 12 3v0a7 7 0 0 0 9.64 10.64z" />';
} else {
body.classList.remove('dark-mode');
icon.innerHTML = '<circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />';
}
}
let darkMode = savedMode === null ? prefersDark : savedMode === 'true';
setDarkMode(darkMode);
document.getElementById('dark_mode_toggle').addEventListener('click', () => {
darkMode = !body.classList.contains('dark-mode');
setDarkMode(darkMode);
localStorage.setItem('darkMode', darkMode);
});
const keyInput = document.getElementById('aeskey');
const keyToggle = document.getElementById('key-toggle');
keyToggle.addEventListener('click', () => {
const type = keyInput.getAttribute('type');
if (type === 'password') {
keyInput.setAttribute('type', 'text');
keyToggle.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>';
} else {
keyInput.setAttribute('type', 'password');
keyToggle.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>';
}
});
await init(); document.getElementById('parser-version').textContent = 'v' + version();
const urlParams = new URLSearchParams(window.location.search);
const urlData = urlParams.get('data');
const urlFormat = urlParams.get('format');
if (urlData) {
document.getElementById('inputstring').value = urlData;
parseInput(urlFormat || 'mermaid');
}
document.getElementById('parse_json').addEventListener('click', () => {
parseInput("json");
});
document.getElementById('parse_yaml').addEventListener('click', () => {
parseInput("yaml");
});
document.getElementById('parse_table').addEventListener('click', () => {
parseInput("table_format");
});
document.getElementById('parse_csv').addEventListener('click', () => {
parseInput("csv");
});
document.getElementById('parse_mermaid').addEventListener('click', () => {
parseInput("mermaid");
});
document.getElementById('parse_hexview').addEventListener('click', () => {
parseInput("hexview");
});
document.getElementById('copy_output').addEventListener('click', () => {
let text;
if (currentFormat === 'mermaid' && window._mermaidSource) {
text = window._mermaidSource;
} else {
const codeElem = document.getElementById('output_code');
text = codeElem ? codeElem.textContent : document.getElementById('output').textContent;
}
navigator.clipboard.writeText(text);
});
document.getElementById('share_button').addEventListener('click', () => {
const inputString = document.getElementById('inputstring').value;
const params = new URLSearchParams({ data: inputString, format: currentFormat });
const shareUrl = `${location.origin}${location.pathname}?${params}`;
navigator.clipboard.writeText(shareUrl);
const btn = document.getElementById('share_button');
btn.title = 'Copied!';
setTimeout(() => { btn.title = 'Copy share link'; }, 2000);
});
document.getElementById('report_issue').addEventListener('click', () => {
window.open('https://github.com/maebli/m-bus-parser/issues/new', '_blank');
});
async function parseInput(format) {
currentFormat = format; const inputString = document.getElementById('inputstring').value;
const aesKey = document.getElementById('aeskey').value.trim();
const params = new URLSearchParams({ data: inputString, format });
history.replaceState(null, '', `?${params}`);
if (aesKey && aesKey.length > 0) {
if (!/^[0-9A-Fa-f]{32}$/.test(aesKey)) {
alert('Invalid AES key format. Please enter exactly 32 hexadecimal characters (0-9, A-F).');
return;
}
}
let formattedResult;
if (format !== 'hexview') {
formattedResult = (aesKey && aesKey.length > 0)
? m_bus_parse_with_key(inputString, format, aesKey)
: m_bus_parse(inputString, format);
}
const outputContainer = document.getElementById('output');
const outputCode = document.getElementById('output_code');
let lang = '', extension = 'txt', mimeType = 'text/plain';
if (format === 'json') { lang = 'json'; extension = 'json'; mimeType = 'application/json'; }
else if (format === 'yaml') { lang = 'yaml'; extension = 'yaml'; mimeType = 'application/x-yaml'; }
else if (format === 'csv') { extension = 'csv'; mimeType = 'text/csv'; }
else if (format === 'table_format') { lang = 'plaintext'; extension = 'txt'; }
else if (format === 'mermaid') { extension = 'mmd'; mimeType = 'text/plain'; }
outputContainer.style.background = '';
outputContainer.style.whiteSpace = '';
if (format === 'hexview') {
const annotatedJson = (aesKey && aesKey.length > 0)
? m_bus_parse_with_key(inputString, "annotated", aesKey)
: m_bus_parse(inputString, "annotated");
window._lastAnnotatedJson = annotatedJson;
extension = 'json'; mimeType = 'application/json';
outputContainer.style.background = 'none';
outputContainer.style.whiteSpace = 'normal';
outputContainer.innerHTML = '';
renderHexViewer(outputContainer, annotatedJson, inputString);
} else if (format === 'csv') {
outputContainer.innerHTML = formattedResult;
} else if (format === 'mermaid') {
window._mermaidSource = formattedResult;
outputContainer.style.background = 'none';
outputContainer.style.whiteSpace = 'normal';
outputContainer.innerHTML = '';
const mermaidDiv = document.createElement('div');
mermaidDiv.id = 'mermaid_diagram';
mermaidDiv.textContent = formattedResult;
outputContainer.appendChild(mermaidDiv);
await mermaid.run({ nodes: [mermaidDiv] });
} else {
outputContainer.innerHTML = '<code id="output_code"></code>';
const newOutputCode = document.getElementById('output_code');
newOutputCode.className = lang ? `language-${lang}` : '';
newOutputCode.textContent = formattedResult;
if (window.hljs && lang) hljs.highlightElement(newOutputCode);
}
if (window._lastDownloadUrl) URL.revokeObjectURL(window._lastDownloadUrl);
const downloadContent = format === 'hexview' ? (window._lastAnnotatedJson || '') : (formattedResult || '');
const blob = new Blob([downloadContent], { type: mimeType });
const url = URL.createObjectURL(blob);
const downloadLink = document.getElementById('download_output');
downloadLink.href = url;
downloadLink.download = `m-bus-output.${extension}`;
window._lastDownloadUrl = url;
}
}
function renderHexViewer(container, annotatedJson, inputString) {
let segments;
try {
segments = JSON.parse(annotatedJson);
} catch (e) {
container.innerHTML = '<pre style="color:red;">Error parsing annotation data: ' + e.message + '</pre>';
return;
}
if (!Array.isArray(segments) || segments.length === 0) {
container.innerHTML = '<pre style="color:red;">No annotation segments returned. Check input.</pre>';
return;
}
const rawBytes = inputString.replace(/\s+/g, '').match(/.{1,2}/g).map(h => parseInt(h, 16));
const byteToSeg = new Array(rawBytes.length).fill(null);
segments.forEach((seg, idx) => {
for (let i = seg.start; i < seg.end && i < rawBytes.length; i++) {
byteToSeg[i] = idx;
}
});
function layerClass(layer) {
if (layer === 'Frame') return 'hex-layer-frame';
if (layer === 'AppHeader') return 'hex-layer-appheader';
return 'hex-layer-record';
}
const viewer = document.createElement('div');
viewer.className = 'hex-viewer';
const leftPanel = document.createElement('div');
leftPanel.className = 'hex-viewer-left';
const rightPanel = document.createElement('div');
rightPanel.className = 'hex-viewer-right';
viewer.appendChild(leftPanel);
viewer.appendChild(rightPanel);
container.appendChild(viewer);
const tooltip = document.createElement('div');
tooltip.className = 'hex-tooltip';
document.body.appendChild(tooltip);
const header = document.createElement('div');
header.className = 'hex-header';
let hdrText = ' Offset ';
for (let i = 0; i < 16; i++) {
hdrText += i.toString(16).toUpperCase().padStart(2, '0') + ' ';
if (i === 7) hdrText += ' ';
}
hdrText += ' ASCII';
header.textContent = hdrText;
leftPanel.appendChild(header);
const byteElements = [];
const asciiElements = [];
for (let row = 0; row * 16 < rawBytes.length; row++) {
const rowDiv = document.createElement('div');
rowDiv.className = 'hex-row';
const offsetSpan = document.createElement('span');
offsetSpan.className = 'hex-offset';
offsetSpan.textContent = ' ' + (row * 16).toString(16).toUpperCase().padStart(4, '0') + ' ';
rowDiv.appendChild(offsetSpan);
const rowAsciiSpans = [];
for (let col = 0; col < 16; col++) {
const byteIdx = row * 16 + col;
if (byteIdx >= rawBytes.length) {
const pad = document.createElement('span');
pad.style.display = 'inline-block';
pad.style.width = '26px';
rowDiv.appendChild(pad);
if (col === 7) {
const gap = document.createElement('span');
gap.className = 'hex-gap';
rowDiv.appendChild(gap);
}
continue;
}
const b = rawBytes[byteIdx];
const segIdx = byteToSeg[byteIdx];
const seg = segIdx !== null ? segments[segIdx] : null;
const span = document.createElement('span');
span.className = 'hex-byte ' + (seg ? layerClass(seg.layer) : '');
span.textContent = b.toString(16).toUpperCase().padStart(2, '0') + ' ';
span.dataset.byteIdx = byteIdx;
if (segIdx !== null) span.dataset.segIdx = segIdx;
byteElements[byteIdx] = span;
rowDiv.appendChild(span);
if (col === 7) {
const gap = document.createElement('span');
gap.className = 'hex-gap';
rowDiv.appendChild(gap);
}
const aSpan = document.createElement('span');
const ch = (b >= 0x21 && b <= 0x7e) ? String.fromCharCode(b) : '\u00b7';
aSpan.className = 'hex-ascii ' + (seg ? layerClass(seg.layer) : '');
aSpan.textContent = ch;
aSpan.dataset.byteIdx = byteIdx;
if (segIdx !== null) aSpan.dataset.segIdx = segIdx;
asciiElements[byteIdx] = aSpan;
rowAsciiSpans.push(aSpan);
}
const sep = document.createElement('span');
sep.className = 'hex-ascii-sep';
rowDiv.appendChild(sep);
rowAsciiSpans.forEach(s => rowDiv.appendChild(s));
leftPanel.appendChild(rowDiv);
}
let activeSegIdx = null;
function highlightSegment(segIdx) {
clearHighlight();
if (segIdx === null || segIdx === undefined) return;
activeSegIdx = segIdx;
const seg = segments[segIdx];
for (let i = seg.start; i < seg.end; i++) {
if (byteElements[i]) byteElements[i].classList.add('highlight');
if (asciiElements[i]) asciiElements[i].classList.add('highlight');
}
const treeItem = rightPanel.querySelector(`[data-seg-idx="${segIdx}"]`);
if (treeItem) {
treeItem.classList.add('active');
treeItem.scrollIntoView({ block: 'nearest' });
const groupBody = treeItem.closest('.hex-tree-group-body');
if (groupBody && groupBody.classList.contains('hidden')) {
groupBody.classList.remove('hidden');
const groupHeader = groupBody.previousElementSibling;
if (groupHeader) groupHeader.classList.remove('collapsed');
}
}
}
function clearHighlight() {
activeSegIdx = null;
byteElements.forEach(el => { if (el) el.classList.remove('highlight'); });
asciiElements.forEach(el => { if (el) el.classList.remove('highlight'); });
rightPanel.querySelectorAll('.hex-tree-item.active').forEach(el => el.classList.remove('active'));
}
leftPanel.addEventListener('mouseover', e => {
const el = e.target.closest('[data-seg-idx]');
if (!el) return;
const segIdx = parseInt(el.dataset.segIdx);
const seg = segments[segIdx];
tooltip.textContent = seg.kind + ': ' + seg.detail;
tooltip.style.display = 'block';
});
leftPanel.addEventListener('mousemove', e => {
tooltip.style.left = (e.clientX + 12) + 'px';
tooltip.style.top = (e.clientY - 30) + 'px';
});
leftPanel.addEventListener('mouseout', e => {
const el = e.target.closest('[data-seg-idx]');
if (el) tooltip.style.display = 'none';
});
leftPanel.addEventListener('click', e => {
const el = e.target.closest('[data-seg-idx]');
if (!el) { clearHighlight(); return; }
const segIdx = parseInt(el.dataset.segIdx);
if (activeSegIdx === segIdx) { clearHighlight(); }
else { highlightSegment(segIdx); }
});
let currentLayer = null;
let currentGroup = null;
let groupBody = null;
segments.forEach((seg, segIdx) => {
if (seg.layer !== currentLayer) {
currentLayer = seg.layer;
currentGroup = null;
const layerLabel = document.createElement('div');
layerLabel.className = 'hex-tree-layer';
const layerNames = { 'Frame': 'Frame', 'AppHeader': 'Application Header', 'RecordField': 'Data Records' };
layerLabel.textContent = layerNames[seg.layer] || seg.layer;
rightPanel.appendChild(layerLabel);
groupBody = null;
}
if (seg.layer === 'RecordField' && seg.group !== null && seg.group !== currentGroup) {
currentGroup = seg.group;
const groupDiv = document.createElement('div');
groupDiv.className = 'hex-tree-group';
const groupHeader = document.createElement('div');
groupHeader.className = 'hex-tree-group-header';
groupHeader.innerHTML = '<span class="arrow">\u25BC</span> Record ' + seg.group;
groupDiv.appendChild(groupHeader);
groupBody = document.createElement('div');
groupBody.className = 'hex-tree-group-body';
groupDiv.appendChild(groupBody);
groupHeader.addEventListener('click', () => {
groupBody.classList.toggle('hidden');
groupHeader.classList.toggle('collapsed');
});
rightPanel.appendChild(groupDiv);
}
const item = document.createElement('div');
item.className = 'hex-tree-item';
item.dataset.segIdx = segIdx;
const offsetStr = seg.end - seg.start === 1
? '[' + seg.start.toString(16).toUpperCase().padStart(2, '0') + ']'
: '[' + seg.start.toString(16).toUpperCase().padStart(2, '0') + '..' + seg.end.toString(16).toUpperCase().padStart(2, '0') + ']';
item.innerHTML =
'<span class="tree-kind">' + escHtml(seg.kind) + '</span>' +
'<span class="tree-detail">' + escHtml(seg.detail) + '</span>' +
'<span class="tree-offset">' + offsetStr + '</span>';
item.addEventListener('click', () => {
if (activeSegIdx === segIdx) { clearHighlight(); }
else { highlightSegment(segIdx); }
});
item.addEventListener('mouseover', () => {
if (activeSegIdx === null) {
const s = segments[segIdx];
for (let i = s.start; i < s.end; i++) {
if (byteElements[i]) byteElements[i].classList.add('highlight');
if (asciiElements[i]) asciiElements[i].classList.add('highlight');
}
}
});
item.addEventListener('mouseout', () => {
if (activeSegIdx === null) {
clearHighlight();
}
});
if (groupBody && seg.group !== null) {
groupBody.appendChild(item);
} else {
rightPanel.appendChild(item);
}
});
const observer = new MutationObserver(() => {
if (!document.body.contains(viewer)) {
tooltip.remove();
observer.disconnect();
}
});
observer.observe(container, { childList: true });
}
function escHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
setup(); </script>
</body>
</html>