mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 08:34:15 +00:00
## Summary Implements M1 of the [channel color highlighting spec](docs/specs/channel-color-highlighting.md) for issue #271. Allows users to assign custom highlight colors to specific hash channels. When a `GRP_TXT` packet arrives with an assigned channel color, the feed row and packets table row get: - **4px colored left border** in the assigned color - **Subtle background tint** (color at 10% opacity) ## What's included ### `public/channel-colors.js` — Storage model - `ChannelColors.get(channel)` → hex color or null - `ChannelColors.set(channel, color)` — assign a color - `ChannelColors.remove(channel)` — clear assignment - `ChannelColors.getAll()` → all assignments - `ChannelColors.getRowStyle(typeName, channel)` → inline CSS string for row highlighting - Uses `localStorage` key `live-channel-colors` - Gracefully handles corrupt/missing localStorage data ### Feed row highlighting (`public/live.js`) - Both `addFeedItem` (live WS) and `addFeedItemDOM` (replay/DB load) apply channel color styles - Reads `decoded.payload.channelName` from the packet ### Packets table highlighting (`public/packets.js`) - `buildFlatRowHtml` and `buildGroupRowHtml` apply channel color styles to `<tr>` elements - Reads channel from `getParsedDecoded(p).channel` ### Tests (`test-channel-colors.js`) - 16 unit tests covering storage CRUD, edge cases (null, empty, corrupt data), and style generation - Tests verify only GRP_TXT/CHAN types get coloring, other types are unaffected ## Design decisions - **Only GRP_TXT/CHAN packets** — other types retain default `TYPE_COLORS` styling - **Channel color takes priority** over default type colors for row highlighting - **No UI for assigning colors yet** — that's M2 (right-click context menu + color picker) - **Storage key abstracted** behind functions to ease future migration if customizer rework (#288) lands - **10% opacity tint** (`#hexcolor` + `1a` suffix) ensures readability in both dark/light modes ## Performance - `getRowStyle()` is O(1) — single localStorage read + JSON parse per call - No per-packet API calls; all data is client-side - No impact on hot rendering paths beyond one localStorage read per row render Closes #271 (M1 only — further milestones in separate PRs) --------- Co-authored-by: you <you@example.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Channel Color Highlighting — Storage Model (M1)
|
||||
*
|
||||
* localStorage key: 'live-channel-colors'
|
||||
* Value: JSON object mapping channel names to hex colors
|
||||
* e.g. { "#wardriving": "#ef4444", "#meshnet": "#3b82f6" }
|
||||
*
|
||||
* Only applies to GRP_TXT packets. Other types retain default styling.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'live-channel-colors';
|
||||
|
||||
function _load() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _save(colors) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(colors));
|
||||
}
|
||||
|
||||
/** Validate hex color format: #RGB or #RRGGBB */
|
||||
var HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
function _isValidHex(color) {
|
||||
return typeof color === 'string' && HEX_RE.test(color);
|
||||
}
|
||||
|
||||
/** Normalize 3-digit hex to 6-digit: #abc → #aabbcc */
|
||||
function _normalize(color) {
|
||||
if (color.length === 4) {
|
||||
return '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assigned color for a channel, or null if unassigned.
|
||||
* @param {string} channel - Channel name (e.g. "#test")
|
||||
* @returns {string|null} Hex color or null
|
||||
*/
|
||||
function getChannelColor(channel) {
|
||||
if (!channel) return null;
|
||||
var colors = _load();
|
||||
return colors[channel] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a color to a channel.
|
||||
* @param {string} channel - Channel name
|
||||
* @param {string} color - Hex color (e.g. "#ef4444")
|
||||
*/
|
||||
function setChannelColor(channel, color) {
|
||||
if (!channel || !color) return;
|
||||
if (!_isValidHex(color)) return;
|
||||
var colors = _load();
|
||||
colors[channel] = _normalize(color);
|
||||
_save(colors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the color assignment for a channel.
|
||||
* @param {string} channel - Channel name
|
||||
*/
|
||||
function removeChannelColor(channel) {
|
||||
if (!channel) return;
|
||||
var colors = _load();
|
||||
delete colors[channel];
|
||||
_save(colors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all channel-color assignments.
|
||||
* @returns {Object} Map of channel name → hex color
|
||||
*/
|
||||
function getAllChannelColors() {
|
||||
return _load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute inline style string for a feed row / table row based on channel color.
|
||||
* Returns empty string if no channel color is assigned.
|
||||
* @param {string} typeName - Packet type name (e.g. "GRP_TXT", "CHAN")
|
||||
* @param {string|null} channel - Channel name from decoded payload
|
||||
* @returns {string} Inline style string or empty
|
||||
*/
|
||||
function getChannelRowStyle(typeName, channel) {
|
||||
// Only GRP_TXT / CHAN packets get channel coloring
|
||||
if (typeName !== 'GRP_TXT' && typeName !== 'CHAN') return '';
|
||||
if (!channel) return '';
|
||||
var color = getChannelColor(channel);
|
||||
if (!color) return '';
|
||||
// 4px left border + 10% opacity background tint
|
||||
return 'border-left:4px solid ' + color + ';background:' + color + '1a;';
|
||||
}
|
||||
|
||||
// Export to window for use by live.js and packets.js
|
||||
window.ChannelColors = {
|
||||
get: getChannelColor,
|
||||
set: setChannelColor,
|
||||
remove: removeChannelColor,
|
||||
getAll: getAllChannelColors,
|
||||
getRowStyle: getChannelRowStyle
|
||||
};
|
||||
})();
|
||||
@@ -94,6 +94,7 @@
|
||||
<script src="home.js?v=__BUST__"></script>
|
||||
<script src="packet-filter.js?v=__BUST__"></script>
|
||||
<script src="packet-helpers.js?v=__BUST__"></script>
|
||||
<script src="channel-colors.js?v=__BUST__"></script>
|
||||
<script src="packets.js?v=__BUST__"></script>
|
||||
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
||||
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
@@ -2495,6 +2495,15 @@
|
||||
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
||||
}
|
||||
|
||||
/** Extract channel row style from a packet (shared by feed item builders). */
|
||||
function _getChannelStyle(pkt) {
|
||||
if (!window.ChannelColors) return '';
|
||||
var d = pkt.decoded || {};
|
||||
var h = d.header || {};
|
||||
var p = d.payload || {};
|
||||
return window.ChannelColors.getRowStyle(h.payloadTypeName || '', p.channelName || null);
|
||||
}
|
||||
|
||||
function addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed) {
|
||||
const text = payload.text || payload.name || '';
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
@@ -2505,6 +2514,9 @@
|
||||
item.setAttribute('tabindex', '0');
|
||||
item.setAttribute('role', 'button');
|
||||
item.style.cursor = 'pointer';
|
||||
// Channel color highlighting for GRP_TXT packets (#271)
|
||||
var _cs = _getChannelStyle(pkt);
|
||||
if (_cs) item.style.cssText += _cs;
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
@@ -2573,6 +2585,9 @@
|
||||
item.setAttribute('role', 'button');
|
||||
if (hash) item.setAttribute('data-hash', hash);
|
||||
item.style.cursor = 'pointer';
|
||||
// Channel color highlighting for GRP_TXT packets (#271)
|
||||
var _chanStyle = _getChannelStyle(pkt);
|
||||
if (_chanStyle) item.style.cssText += _chanStyle;
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
|
||||
+7
-2
@@ -1124,7 +1124,10 @@
|
||||
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const isSingle = p.count <= 1;
|
||||
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row">
|
||||
// Channel color highlighting (#271)
|
||||
const _grpDecoded = getParsedDecoded(p) || {};
|
||||
const _grpChanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(_grpDecoded.type || groupTypeName, _grpDecoded.channel) : '';
|
||||
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_grpChanStyle ? ' style="' + _grpChanStyle + '"' : ''}>
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
@@ -1174,11 +1177,13 @@
|
||||
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
|
||||
const typeName = payloadTypeName(p.payload_type);
|
||||
const typeClass = payloadTypeColor(p.payload_type);
|
||||
// Channel color highlighting (#271)
|
||||
const _chanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(decoded.type || typeName, decoded.channel) : '';
|
||||
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const pathStr = renderPath(pathHops, p.observer_id);
|
||||
const detail = getDetailPreview(decoded);
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_chanStyle ? ' style="' + _chanStyle + '"' : ''}>
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* Unit tests for channel color highlighting (M1) — #271 */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ✅ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(` ❌ ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build minimal sandbox with localStorage mock
|
||||
function makeSandbox() {
|
||||
const store = {};
|
||||
const localStorage = {
|
||||
getItem: function(k) { return store[k] !== undefined ? store[k] : null; },
|
||||
setItem: function(k, v) { store[k] = String(v); },
|
||||
removeItem: function(k) { delete store[k]; },
|
||||
clear: function() { for (var k in store) delete store[k]; }
|
||||
};
|
||||
const ctx = {
|
||||
window: {},
|
||||
localStorage: localStorage,
|
||||
console: console,
|
||||
JSON: JSON,
|
||||
};
|
||||
ctx.window.ChannelColors = undefined;
|
||||
vm.createContext(ctx);
|
||||
const src = fs.readFileSync(__dirname + '/public/channel-colors.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
console.log('\n🎨 Channel Colors — Storage CRUD');
|
||||
|
||||
test('getChannelColor returns null for unassigned channel', function() {
|
||||
const ctx = makeSandbox();
|
||||
assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
|
||||
});
|
||||
|
||||
test('setChannelColor + getChannelColor round-trip', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#sf', '#ef4444');
|
||||
assert.strictEqual(ctx.window.ChannelColors.get('#sf'), '#ef4444');
|
||||
});
|
||||
|
||||
test('setChannelColor overwrites existing color', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#sf', '#ef4444');
|
||||
ctx.window.ChannelColors.set('#sf', '#3b82f6');
|
||||
assert.strictEqual(ctx.window.ChannelColors.get('#sf'), '#3b82f6');
|
||||
});
|
||||
|
||||
test('removeChannelColor removes assignment', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#test', '#ff0000');
|
||||
ctx.window.ChannelColors.remove('#test');
|
||||
assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
|
||||
});
|
||||
|
||||
test('removeChannelColor on non-existent channel is no-op', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.remove('#nonexistent');
|
||||
assert.deepStrictEqual(ctx.window.ChannelColors.getAll(), {});
|
||||
});
|
||||
|
||||
test('getAllChannelColors returns all assignments', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#a', '#111111');
|
||||
ctx.window.ChannelColors.set('#b', '#222222');
|
||||
const all = ctx.window.ChannelColors.getAll();
|
||||
assert.strictEqual(JSON.stringify(all), JSON.stringify({ '#a': '#111111', '#b': '#222222' }));
|
||||
});
|
||||
|
||||
test('getAllChannelColors returns empty object when none set', function() {
|
||||
const ctx = makeSandbox();
|
||||
assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
|
||||
});
|
||||
|
||||
test('handles corrupt localStorage gracefully', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.localStorage.setItem('live-channel-colors', 'not-json{{{');
|
||||
assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
|
||||
assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
|
||||
});
|
||||
|
||||
test('set with null/empty channel is no-op', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('', '#ff0000');
|
||||
ctx.window.ChannelColors.set(null, '#ff0000');
|
||||
assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
|
||||
});
|
||||
|
||||
test('set rejects invalid hex colors', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#ch', 'red');
|
||||
ctx.window.ChannelColors.set('#ch', '#xyz');
|
||||
ctx.window.ChannelColors.set('#ch', '#12345');
|
||||
ctx.window.ChannelColors.set('#ch', '#1234567');
|
||||
ctx.window.ChannelColors.set('#ch', 'ff0000');
|
||||
assert.strictEqual(ctx.window.ChannelColors.get('#ch'), null);
|
||||
});
|
||||
|
||||
test('set normalizes 3-digit hex to 6-digit', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#ch', '#abc');
|
||||
assert.strictEqual(ctx.window.ChannelColors.get('#ch'), '#aabbcc');
|
||||
});
|
||||
|
||||
test('set accepts valid 6-digit hex', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#ch', '#ef4444');
|
||||
assert.strictEqual(ctx.window.ChannelColors.get('#ch'), '#ef4444');
|
||||
});
|
||||
|
||||
test('get with null/empty channel returns null', function() {
|
||||
const ctx = makeSandbox();
|
||||
assert.strictEqual(ctx.window.ChannelColors.get(''), null);
|
||||
assert.strictEqual(ctx.window.ChannelColors.get(null), null);
|
||||
});
|
||||
|
||||
console.log('\n🎨 Channel Colors — Row Style Generation');
|
||||
|
||||
test('getRowStyle returns empty string for non-GRP_TXT types', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#test', '#ff0000');
|
||||
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('ADVERT', '#test'), '');
|
||||
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('TXT_MSG', '#test'), '');
|
||||
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('ACK', '#test'), '');
|
||||
});
|
||||
|
||||
test('getRowStyle returns empty string for unassigned channel', function() {
|
||||
const ctx = makeSandbox();
|
||||
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#unassigned'), '');
|
||||
});
|
||||
|
||||
test('getRowStyle returns empty string for null channel', function() {
|
||||
const ctx = makeSandbox();
|
||||
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', null), '');
|
||||
});
|
||||
|
||||
test('getRowStyle returns border + background for assigned GRP_TXT channel', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#sf', '#ef4444');
|
||||
const style = ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#sf');
|
||||
assert.ok(style.includes('border-left:4px solid #ef4444'), 'should have left border');
|
||||
assert.ok(style.includes('background:#ef44441a'), 'should have 10% opacity background');
|
||||
});
|
||||
|
||||
test('getRowStyle works with CHAN type (alias for GRP_TXT)', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#mesh', '#3b82f6');
|
||||
const style = ctx.window.ChannelColors.getRowStyle('CHAN', '#mesh');
|
||||
assert.ok(style.includes('border-left:4px solid #3b82f6'), 'should have left border');
|
||||
assert.ok(style.includes('background:#3b82f61a'), 'should have background tint');
|
||||
});
|
||||
|
||||
test('getRowStyle returns empty when channel has no assigned color', function() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.ChannelColors.set('#other', '#ff0000');
|
||||
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#nope'), '');
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user