diff --git a/public/hash-color.js b/public/hash-color.js index 6196a422..15b1d78f 100644 --- a/public/hash-color.js +++ b/public/hash-color.js @@ -1,5 +1,5 @@ /* hash-color.js — Deterministic HSL color from packet hash - * IIFE attaching window.HashColor = { hashToHsl } + * IIFE attaching window.HashColor = { hashToHsl, hashToOutline } * Pure function: no DOM access, no state, works in Node vm.createContext sandbox. */ (function() { @@ -7,42 +7,64 @@ /** * Derive a deterministic HSL color string from a hex hash. - * @param {string|null|undefined} hashHex - Hex string (e.g. "a1b2c3...") + * Uses bytes 0-1 for hue, byte 2 for saturation, byte 3 for lightness. + * Produces bright vivid fills; contrast is provided by a dark outline (hashToOutline). + * @param {string|null|undefined} hashHex - Hex string (e.g. "a1b2c3d4...") * @param {string} theme - "light" or "dark" * @returns {string} CSS hsl() string */ function hashToHsl(hashHex, theme) { - if (!hashHex || hashHex.length < 4) { + if (!hashHex || hashHex.length < 8) { return 'hsl(0, 0%, 50%)'; } - // First 2 bytes → hue (0-360) var b0 = parseInt(hashHex.slice(0, 2), 16) || 0; var b1 = parseInt(hashHex.slice(2, 4), 16) || 0; + var b2 = parseInt(hashHex.slice(4, 6), 16) || 0; + var b3 = parseInt(hashHex.slice(6, 8), 16) || 0; + + // Hue: 0-360 from bytes 0-1 (16-bit) var hue = Math.round(((b0 << 8) | b1) / 65535 * 360); - - var S = 70; + // Saturation: 55-95% from byte 2 + var S = 55 + Math.round(b2 / 255 * 40); + // Lightness: vivid range per theme from byte 3 + // Light: 50-65%, Dark: 55-72% var L; - if (theme === 'dark') { - L = 65; + L = 55 + Math.round(b3 / 255 * 17); } else { - // Light theme: base L ensures WCAG ≥3.0 contrast against --content-bg (#f4f5f7, style.css:32) - // Green/cyan zone (hue 45-195) needs lower L due to high perceptual luminance - if (hue >= 45 && hue <= 195) { - L = 30; - } else { - L = 38; - } + L = 50 + Math.round(b3 / 255 * 15); } return 'hsl(' + hue + ', ' + S + '%, ' + L + '%)'; } + /** + * Derive a dark outline color (same hue) for contrast against backgrounds. + * @param {string|null|undefined} hashHex - Hex string + * @param {string} theme - "light" or "dark" + * @returns {string} CSS hsl() string + */ + function hashToOutline(hashHex, theme) { + if (!hashHex || hashHex.length < 8) { + return 'hsl(0, 0%, 30%)'; + } + + var b0 = parseInt(hashHex.slice(0, 2), 16) || 0; + var b1 = parseInt(hashHex.slice(2, 4), 16) || 0; + var hue = Math.round(((b0 << 8) | b1) / 65535 * 360); + + // Dark outline: same hue, low lightness for contrast + if (theme === 'dark') { + return 'hsl(' + hue + ', 30%, 15%)'; + } + return 'hsl(' + hue + ', 70%, 25%)'; + } + // Export if (typeof window !== 'undefined') { - window.HashColor = { hashToHsl: hashToHsl }; + window.HashColor = { hashToHsl: hashToHsl, hashToOutline: hashToOutline }; } else if (typeof module !== 'undefined') { - module.exports = { hashToHsl: hashToHsl }; + module.exports = { hashToHsl: hashToHsl, hashToOutline: hashToOutline }; } })(); diff --git a/public/live.js b/public/live.js index 7055c408..a59c2353 100644 --- a/public/live.js +++ b/public/live.js @@ -23,6 +23,8 @@ let matrixMode = localStorage.getItem('live-matrix-mode') === 'true'; let matrixRain = localStorage.getItem('live-matrix-rain') === 'true'; let colorByHash = localStorage.getItem('meshcore-color-packets-by-hash') !== 'false'; + /** Current theme string for hash-color functions. */ + function _liveTheme() { return document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); } let nodeFilterKeys = (localStorage.getItem('live-node-filter') || '').split(',').map(s => s.trim()).filter(Boolean); let nodeFilterTotal = 0; let nodeFilterShown = 0; @@ -2704,13 +2706,14 @@ const mainOpacity = overrideOpacity ?? 0.8; const isDashed = overrideOpacity != null; - // Hash-derived color for fill + contrail (when toggle ON and not ghost/dashed line) + // Hash-derived color for fill + contrail + outline (when toggle ON and not ghost/dashed line) var hashFill = '#fff'; + var hashOutline = color; var contrailColor = color; if (colorByHash && hash && !isDashed && window.HashColor) { - var theme = (document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')); - var hsl = HashColor.hashToHsl(hash, theme); + var hsl = HashColor.hashToHsl(hash, _liveTheme()); hashFill = hsl; + hashOutline = HashColor.hashToOutline(hash, _liveTheme()); contrailColor = hsl; } @@ -2719,12 +2722,13 @@ }).addTo(pathsLayer); const line = L.polyline([from], { - color: color, weight: isDashed ? 1.5 : 2, opacity: mainOpacity, lineCap: 'round', + color: (colorByHash && hash && !isDashed && window.HashColor) ? hashFill : color, + weight: isDashed ? 1.5 : 2, opacity: mainOpacity, lineCap: 'round', dashArray: isDashed ? '4 6' : null }).addTo(pathsLayer); const dot = L.circleMarker(from, { - radius: 3.5, fillColor: hashFill, fillOpacity: 1, color: color, weight: 1.5 + radius: 3.5, fillColor: hashFill, fillOpacity: 1, color: hashOutline, weight: 1.5 }).addTo(animLayer); let lastStep = performance.now(); @@ -2856,6 +2860,10 @@ item.setAttribute('tabindex', '0'); item.setAttribute('role', 'button'); item.style.cursor = 'pointer'; + // Hash-color stripe for feed items (mirrors packets table border-left) + if (colorByHash && pkt.hash && window.HashColor) { + item.style.borderLeft = '4px solid ' + HashColor.hashToHsl(pkt.hash, _liveTheme()); + } // Channel color highlighting for GRP_TXT packets (#271) var _cs = _getChannelStyle(pkt); if (_cs) item.style.cssText += _cs; @@ -2939,6 +2947,10 @@ item.setAttribute('role', 'button'); if (hash) item.setAttribute('data-hash', hash); item.style.cursor = 'pointer'; + // Hash-color stripe for feed items (mirrors packets table border-left) + if (colorByHash && hash && window.HashColor) { + item.style.borderLeft = '4px solid ' + HashColor.hashToHsl(hash, _liveTheme()); + } // Channel color highlighting for GRP_TXT packets (#271) var _chanStyle = _getChannelStyle(pkt); if (_chanStyle) item.style.cssText += _chanStyle; diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 81af5d1b..f7057e8b 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -2211,6 +2211,49 @@ async function run() { await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true')); }); + // --- Live feed hash-color stripe --- + await test('Live feed items have border-left stripe when toggle ON', async () => { + await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true')); + await page.goto(BASE + '/#/live'); + await page.waitForTimeout(3000); // allow feed to populate + const hasStripe = await page.evaluate(() => { + const items = document.querySelectorAll('.live-feed-item'); + for (const item of items) { + if ((item.getAttribute('style') || item.style.cssText || '').includes('border-left')) return true; + } + return false; + }); + // May not have live packets in fixture — skip if no feed items + const itemCount = await page.evaluate(() => document.querySelectorAll('.live-feed-item').length); + if (itemCount === 0) { + console.log(' (skipped — no live feed items in fixture)'); + return; + } + assert(hasStripe, 'At least one .live-feed-item should have hash-color border-left stripe when toggle ON'); + }); + + // --- Map polyline uses hash color --- + await test('Map trace polyline uses hash-derived color when toggle ON', async () => { + await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true')); + await page.goto(BASE + '/#/live'); + await page.waitForTimeout(3000); + // Check if any polyline SVG path has an hsl stroke + const hasHslPolyline = await page.evaluate(() => { + const paths = document.querySelectorAll('path.leaflet-interactive'); + for (const p of paths) { + const stroke = p.getAttribute('stroke') || ''; + if (stroke.startsWith('hsl(')) return true; + } + return false; + }); + const pathCount = await page.evaluate(() => document.querySelectorAll('path.leaflet-interactive').length); + if (pathCount === 0) { + console.log(' (skipped — no polyline paths on map in fixture)'); + return; + } + assert(hasHslPolyline, 'At least one map polyline should have hsl() stroke color from hash'); + }); + await browser.close(); // Summary diff --git a/test-hash-color.js b/test-hash-color.js index 0dc9ca8b..fbba2992 100644 --- a/test-hash-color.js +++ b/test-hash-color.js @@ -1,5 +1,6 @@ /* test-hash-color.js — Unit tests for hash-color.js (vm.createContext sandbox) - * Tests: purity, theme split, yellow-zone clamp, sentinel, WCAG sweep + * Tests: purity, theme split, saturation variability, lightness variability, + * outline darker than fill, sentinel, perceptual distance */ 'use strict'; const vm = require('vm'); @@ -24,6 +25,12 @@ function assert(cond, msg) { else { failed++; console.error(' ✗ ' + msg); } } +function parseHsl(str) { + const m = str.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/); + if (!m) return null; + return { h: parseInt(m[1]), s: parseInt(m[2]), l: parseInt(m[3]) }; +} + // --- Purity: same input → same output --- console.log('Purity:'); const r1 = HashColor.hashToHsl('a1b2c3d4', 'light'); @@ -34,110 +41,109 @@ assert(r1 === r3, 'Third call still identical (no internal state)'); // --- Theme split: light vs dark produce different L --- console.log('Theme split:'); -const light = HashColor.hashToHsl('ff00aabb', 'light'); -const dark = HashColor.hashToHsl('ff00aabb', 'dark'); +const light = HashColor.hashToHsl('ff00aa80', 'light'); +const dark = HashColor.hashToHsl('ff00aa80', 'dark'); assert(light !== dark, 'Light and dark produce different colors for same hash'); -// Extract L values -const lightL = parseInt(light.match(/(\d+)%\)$/)[1]); -const darkL = parseInt(dark.match(/(\d+)%\)$/)[1]); -assert(lightL <= 45, 'Light theme L ≤ 45% (got ' + lightL + ')'); -assert(darkL >= 60, 'Dark theme L ≥ 60% (got ' + darkL + ')'); +const lightP = parseHsl(light); +const darkP = parseHsl(dark); +assert(lightP.l >= 50 && lightP.l <= 65, 'Light theme L in [50,65] (got ' + lightP.l + ')'); +assert(darkP.l >= 55 && darkP.l <= 72, 'Dark theme L in [55,72] (got ' + darkP.l + ')'); -// --- Yellow-zone clamp: hue ∈ [45°, 75°] → L=45% in light mode --- -console.log('Yellow-zone clamp (hue 45-195 → L=30%):'); -// Hue 60° → bytes: 60/360 * 65535 = 10922 = 0x2AAA → hex "2aaa" -const yellow = HashColor.hashToHsl('2aaa0000', 'light'); -const yellowL = parseInt(yellow.match(/(\d+)%\)$/)[1]); -const yellowH = parseInt(yellow.match(/hsl\((\d+)/)[1]); -assert(yellowH >= 45 && yellowH <= 75, 'Yellow zone hue confirmed (' + yellowH + '°)'); -assert(yellowL === 30, 'Yellow-zone L clamped to 30% in light (got ' + yellowL + ')'); -// Same hash in dark should NOT clamp -const yellowDark = HashColor.hashToHsl('2aaa0000', 'dark'); -const yellowDarkL = parseInt(yellowDark.match(/(\d+)%\)$/)[1]); -assert(yellowDarkL === 65, 'Yellow-zone NOT clamped in dark (got ' + yellowDarkL + ')'); +// --- Saturation varies with byte 2 --- +console.log('Saturation variability (byte 2):'); +const lowSat = HashColor.hashToHsl('000000ff', 'light'); // byte2=0x00 +const highSat = HashColor.hashToHsl('0000ffff', 'light'); // byte2=0xff +const lowSatP = parseHsl(lowSat); +const highSatP = parseHsl(highSat); +assert(lowSatP.s === 55, 'byte2=0x00 → S=55% (got ' + lowSatP.s + ')'); +assert(highSatP.s === 95, 'byte2=0xff → S=95% (got ' + highSatP.s + ')'); +// Mid value +const midSat = HashColor.hashToHsl('00008000', 'light'); // byte2=0x80 +const midSatP = parseHsl(midSat); +assert(midSatP.s > 55 && midSatP.s < 95, 'byte2=0x80 → S between 55 and 95 (got ' + midSatP.s + ')'); -// --- Sentinel: null/empty hash --- +// --- Lightness varies with byte 3 --- +console.log('Lightness variability (byte 3):'); +const lowL = HashColor.hashToHsl('00000000', 'light'); // byte3=0x00 +const highL = HashColor.hashToHsl('000000ff', 'light'); // byte3=0xff +const lowLP = parseHsl(lowL); +const highLP = parseHsl(highL); +assert(lowLP.l === 50, 'byte3=0x00 light → L=50 (got ' + lowLP.l + ')'); +assert(highLP.l === 65, 'byte3=0xff light → L=65 (got ' + highLP.l + ')'); +const lowLD = HashColor.hashToHsl('00000000', 'dark'); +const highLD = HashColor.hashToHsl('000000ff', 'dark'); +assert(parseHsl(lowLD).l === 55, 'byte3=0x00 dark → L=55 (got ' + parseHsl(lowLD).l + ')'); +assert(parseHsl(highLD).l === 72, 'byte3=0xff dark → L=72 (got ' + parseHsl(highLD).l + ')'); + +// --- Outline is darker than fill --- +console.log('Outline darker than fill:'); +['a1b2c3d4', 'ff00aa80', '12345678', 'deadbeef'].forEach(h => { + ['light', 'dark'].forEach(theme => { + const fill = parseHsl(HashColor.hashToHsl(h, theme)); + const outline = parseHsl(HashColor.hashToOutline(h, theme)); + assert(outline.l < fill.l, 'Outline L(' + outline.l + ') < Fill L(' + fill.l + ') for ' + h + '/' + theme); + }); +}); + +// --- Outline same hue as fill --- +console.log('Outline same hue as fill:'); +['a1b2c3d4', 'deadbeef'].forEach(h => { + const fill = parseHsl(HashColor.hashToHsl(h, 'light')); + const outline = parseHsl(HashColor.hashToOutline(h, 'light')); + assert(fill.h === outline.h, 'Hue matches: fill=' + fill.h + ' outline=' + outline.h + ' for ' + h); +}); + +// --- Sentinel: null/empty/short hash --- console.log('Sentinel:'); assert(HashColor.hashToHsl(null, 'light') === 'hsl(0, 0%, 50%)', 'null → sentinel'); assert(HashColor.hashToHsl('', 'light') === 'hsl(0, 0%, 50%)', 'empty string → sentinel'); assert(HashColor.hashToHsl('ab', 'dark') === 'hsl(0, 0%, 50%)', 'too short (2 chars) → sentinel'); +assert(HashColor.hashToHsl('abcdef', 'dark') === 'hsl(0, 0%, 50%)', '6 chars (need 8) → sentinel'); assert(HashColor.hashToHsl(undefined, 'dark') === 'hsl(0, 0%, 50%)', 'undefined → sentinel'); +assert(HashColor.hashToOutline(null, 'light') === 'hsl(0, 0%, 30%)', 'null outline → sentinel'); // --- Variability: different hashes → different colors (anti-tautology) --- console.log('Variability (anti-tautology):'); const colors = new Set(); -['00000000', '80000000', 'ff000000', '00ff0000', 'ffff0000'].forEach(h => { +['00008080', '80008080', 'ff008080', '00ff8080', 'ffff8080'].forEach(h => { colors.add(HashColor.hashToHsl(h, 'light')); }); assert(colors.size >= 4, 'At least 4 distinct colors from 5 different hashes (got ' + colors.size + ')'); -const darkColors = new Set(); -['11110000', '55550000', '99990000', 'dddd0000'].forEach(h => { - darkColors.add(HashColor.hashToHsl(h, 'dark')); -}); -assert(darkColors.size >= 3, 'At least 3 distinct dark colors from 4 hashes (got ' + darkColors.size + ')'); - -// Another variability: consecutive hashes differ -const c1 = HashColor.hashToHsl('01000000', 'light'); -const c2 = HashColor.hashToHsl('02000000', 'light'); +// Adjacent hashes differ +const c1 = HashColor.hashToHsl('01008080', 'light'); +const c2 = HashColor.hashToHsl('02008080', 'light'); assert(c1 !== c2, 'Adjacent hashes produce different colors'); -// --- WCAG contrast sweep --- -// Background constants from style.css:37 (light --content-bg) and style.css:61 (dark --content-bg) -console.log('WCAG contrast sweep (≥3.0 against --content-bg):'); - -function hexToRgb(hex) { - hex = hex.replace('#', ''); - return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)]; +// --- Perceptual distance: sample 50 hashes, compute pairwise HSL distance --- +console.log('Perceptual distance (50 sample hashes):'); +function hslDistance(a, b) { + // Simple cylindrical distance: weight hue wrap, sat, lightness + var dh = Math.min(Math.abs(a.h - b.h), 360 - Math.abs(a.h - b.h)) / 180; // 0-1 + var ds = Math.abs(a.s - b.s) / 100; // 0-1 + var dl = Math.abs(a.l - b.l) / 100; // 0-1 + return Math.sqrt(dh*dh + ds*ds + dl*dl); } -function hslToRgb(h, s, l) { - s /= 100; l /= 100; - var c = (1 - Math.abs(2*l - 1)) * s; - var x = c * (1 - Math.abs((h/60) % 2 - 1)); - var m = l - c/2; - var r, g, b; - if (h < 60) { r=c; g=x; b=0; } - else if (h < 120) { r=x; g=c; b=0; } - else if (h < 180) { r=0; g=c; b=x; } - else if (h < 240) { r=0; g=x; b=c; } - else if (h < 300) { r=x; g=0; b=c; } - else { r=c; g=0; b=x; } - return [Math.round((r+m)*255), Math.round((g+m)*255), Math.round((b+m)*255)]; +const deterministicHashes = []; +for (var i = 0; i < 50; i++) { + var hex = ('0000000' + (i * 5347 + 12345).toString(16)).slice(-8); + deterministicHashes.push(hex); } -function luminance(rgb) { - var a = rgb.map(function(v) { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); }); - return 0.2126*a[0] + 0.7152*a[1] + 0.0722*a[2]; +const parsedColors = deterministicHashes.map(h => parseHsl(HashColor.hashToHsl(h, 'light'))); +var distances = []; +for (var i = 0; i < parsedColors.length; i++) { + for (var j = i + 1; j < parsedColors.length; j++) { + distances.push(hslDistance(parsedColors[i], parsedColors[j])); + } } - -function contrastRatio(rgb1, rgb2) { - var l1 = luminance(rgb1), l2 = luminance(rgb2); - var lighter = Math.max(l1, l2), darker = Math.min(l1, l2); - return (lighter + 0.05) / (darker + 0.05); -} - -// Light bg: #f4f5f7 (style.css:33 --surface-0, referenced by --content-bg at line 37) -var lightBg = hexToRgb('#f4f5f7'); -// Dark bg: #0f0f23 (style.css:57 --surface-0 dark, referenced by --content-bg at line 61) -var darkBg = hexToRgb('#0f0f23'); - -var wcagFails = []; -for (var hue = 0; hue < 360; hue += 15) { - // Simulate hashToHsl output for this hue - // Light theme - var lL = (hue >= 45 && hue <= 195) ? 30 : 38; - var lightRgb = hslToRgb(hue, 70, lL); - var lRatio = contrastRatio(lightRgb, lightBg); - if (lRatio < 3.0) wcagFails.push('light hue=' + hue + ' ratio=' + lRatio.toFixed(2)); - - // Dark theme - var darkRgb = hslToRgb(hue, 70, 65); - var dRatio = contrastRatio(darkRgb, darkBg); - if (dRatio < 3.0) wcagFails.push('dark hue=' + hue + ' ratio=' + dRatio.toFixed(2)); -} - -assert(wcagFails.length === 0, 'All hues pass WCAG ≥3.0 contrast' + (wcagFails.length ? ' FAILURES: ' + wcagFails.join('; ') : '')); +var avgDist = distances.reduce((a, b) => a + b, 0) / distances.length; +var minDist = Math.min(...distances); +console.log(' Avg pairwise HSL distance: ' + avgDist.toFixed(4)); +console.log(' Min pairwise HSL distance: ' + minDist.toFixed(4)); +assert(avgDist > 0.15, 'Average pairwise distance > 0.15 (got ' + avgDist.toFixed(4) + ')'); +assert(minDist > 0.01, 'Min pairwise distance > 0.01 (got ' + minDist.toFixed(4) + ')'); // --- Summary --- console.log('\n' + passed + ' passed, ' + failed + ' failed');