minterm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/themes.js ADDED
@@ -0,0 +1,149 @@
1
+ /** Built-in color schemes — apply via applyTheme('amber') or pass a custom object */
2
+
3
+ export const themes = {
4
+ // ── Default — the original vivid cyan ──
5
+ cyber: {
6
+ '--mt-font': "'Courier New', 'Consolas', monospace",
7
+ '--mt-font-size': '15px',
8
+ '--mt-accent': '#0aa',
9
+ '--mt-accent-bright': '#0ff',
10
+ '--mt-bg': '#0a0a0a',
11
+ '--mt-border': '#0aa',
12
+ '--mt-green': '#0f0',
13
+ '--mt-red': '#f44',
14
+ '--mt-yellow': '#ff0',
15
+ '--mt-cyan': '#0ff',
16
+ '--mt-magenta': '#f0f',
17
+ '--mt-orange': '#f80',
18
+ '--mt-white': '#fff',
19
+ '--mt-dim': '#666',
20
+ '--mt-text': '#ccc',
21
+ '--mt-titlebar-bg': '#0aa',
22
+ '--mt-titlebar-fg': '#000',
23
+ '--mt-glow': 'rgba(0, 170, 170, 0.12)',
24
+ '--mt-glow-strong': 'rgba(0, 170, 170, 0.3)',
25
+ },
26
+
27
+ // ── Muted / low-fatigue themes for long sessions ──
28
+
29
+ amber: {
30
+ '--mt-font': "'VT323', 'Courier New', monospace",
31
+ '--mt-font-size': '16px',
32
+ '--mt-accent': '#a08030',
33
+ '--mt-accent-bright': '#c8a048',
34
+ '--mt-bg': '#0e0c08',
35
+ '--mt-border': '#7a6428',
36
+ '--mt-green': '#6a9a4a',
37
+ '--mt-red': '#a04a3a',
38
+ '--mt-yellow': '#c8a048',
39
+ '--mt-cyan': '#a08858',
40
+ '--mt-magenta': '#9a6878',
41
+ '--mt-orange': '#b07838',
42
+ '--mt-white': '#d0c8a8',
43
+ '--mt-dim': '#5a5038',
44
+ '--mt-text': '#a89878',
45
+ '--mt-titlebar-bg': '#7a6428',
46
+ '--mt-titlebar-fg': '#0e0c08',
47
+ '--mt-glow': 'rgba(160, 128, 48, 0.08)',
48
+ '--mt-glow-strong': 'rgba(160, 128, 48, 0.18)',
49
+ },
50
+ phosphor: {
51
+ '--mt-font': "'IBM Plex Mono', 'Consolas', monospace",
52
+ '--mt-font-size': '14px',
53
+ '--mt-accent': '#3a8a3a',
54
+ '--mt-accent-bright': '#58b858',
55
+ '--mt-bg': '#080e08',
56
+ '--mt-border': '#2e6e2e',
57
+ '--mt-green': '#58b858',
58
+ '--mt-red': '#a85050',
59
+ '--mt-yellow': '#90a838',
60
+ '--mt-cyan': '#58a868',
61
+ '--mt-magenta': '#7a6a98',
62
+ '--mt-orange': '#a88038',
63
+ '--mt-white': '#b0c8a8',
64
+ '--mt-dim': '#385838',
65
+ '--mt-text': '#7a9a70',
66
+ '--mt-titlebar-bg': '#2e6e2e',
67
+ '--mt-titlebar-fg': '#080e08',
68
+ '--mt-glow': 'rgba(58, 138, 58, 0.08)',
69
+ '--mt-glow-strong': 'rgba(58, 138, 58, 0.18)',
70
+ },
71
+ hotline: {
72
+ '--mt-font': "'Share Tech Mono', 'Courier New', monospace",
73
+ '--mt-font-size': '15px',
74
+ '--mt-accent': '#9a4070',
75
+ '--mt-accent-bright': '#c05888',
76
+ '--mt-bg': '#0e080c',
77
+ '--mt-border': '#7a3058',
78
+ '--mt-green': '#5a9a68',
79
+ '--mt-red': '#b05050',
80
+ '--mt-yellow': '#b8a050',
81
+ '--mt-cyan': '#6898a8',
82
+ '--mt-magenta': '#c05888',
83
+ '--mt-orange': '#b07848',
84
+ '--mt-white': '#d0c0c8',
85
+ '--mt-dim': '#5a4050',
86
+ '--mt-text': '#a88898',
87
+ '--mt-titlebar-bg': '#7a3058',
88
+ '--mt-titlebar-fg': '#0e080c',
89
+ '--mt-glow': 'rgba(154, 64, 112, 0.08)',
90
+ '--mt-glow-strong': 'rgba(154, 64, 112, 0.18)',
91
+ },
92
+ ice: {
93
+ '--mt-font': "'Fira Code', 'Consolas', monospace",
94
+ '--mt-font-size': '14px',
95
+ '--mt-accent': '#5068a0',
96
+ '--mt-accent-bright': '#7088c0',
97
+ '--mt-bg': '#08090e',
98
+ '--mt-border': '#405080',
99
+ '--mt-green': '#509878',
100
+ '--mt-red': '#a85858',
101
+ '--mt-yellow': '#b0a060',
102
+ '--mt-cyan': '#6890b0',
103
+ '--mt-magenta': '#8868a8',
104
+ '--mt-orange': '#a88050',
105
+ '--mt-white': '#c0c4d0',
106
+ '--mt-dim': '#484e60',
107
+ '--mt-text': '#909ab0',
108
+ '--mt-titlebar-bg': '#405080',
109
+ '--mt-titlebar-fg': '#08090e',
110
+ '--mt-glow': 'rgba(80, 104, 160, 0.08)',
111
+ '--mt-glow-strong': 'rgba(80, 104, 160, 0.18)',
112
+ },
113
+ slate: {
114
+ '--mt-font': "'JetBrains Mono', 'Menlo', monospace",
115
+ '--mt-font-size': '14px',
116
+ '--mt-accent': '#6a7a88',
117
+ '--mt-accent-bright': '#8898a8',
118
+ '--mt-bg': '#0c0d0e',
119
+ '--mt-border': '#4a5a68',
120
+ '--mt-green': '#58906a',
121
+ '--mt-red': '#985858',
122
+ '--mt-yellow': '#a89858',
123
+ '--mt-cyan': '#6888a0',
124
+ '--mt-magenta': '#886888',
125
+ '--mt-orange': '#a08050',
126
+ '--mt-white': '#b8bcc0',
127
+ '--mt-dim': '#484c50',
128
+ '--mt-text': '#8a9098',
129
+ '--mt-titlebar-bg': '#4a5a68',
130
+ '--mt-titlebar-fg': '#0c0d0e',
131
+ '--mt-glow': 'rgba(106, 122, 136, 0.06)',
132
+ '--mt-glow-strong': 'rgba(106, 122, 136, 0.14)',
133
+ },
134
+ };
135
+
136
+ /**
137
+ * Apply a theme by name or custom object.
138
+ * @param {string|object} theme — name from built-in themes, or { '--mt-accent': '#f00', ... }
139
+ * @param {HTMLElement} [root=document.documentElement] — element to set CSS vars on
140
+ */
141
+ export function applyTheme(theme, root) {
142
+ const el = root || document.documentElement;
143
+ const vars = typeof theme === 'string' ? themes[theme] : theme;
144
+ if (!vars) return;
145
+ const keys = Object.keys(vars);
146
+ for (let i = 0, n = keys.length; i < n; i++) {
147
+ el.style.setProperty(keys[i], vars[keys[i]]);
148
+ }
149
+ }
@@ -0,0 +1,55 @@
1
+ /** Animated pulsing activity bars — pure CSS animation */
2
+
3
+ import { BaseWidget } from './base-widget.js';
4
+
5
+ export class ActivityBars extends BaseWidget {
6
+ /**
7
+ * @param {HTMLElement} container
8
+ * @param {object} [opts]
9
+ * @param {number} [opts.count=8] — number of bars
10
+ * @param {string} [opts.classPrefix='mt']
11
+ */
12
+ constructor(container, opts = {}) {
13
+ super(container, opts);
14
+ this._count = opts.count ?? 8;
15
+ this._data = null;
16
+ }
17
+
18
+ /**
19
+ * Update bars. data = { bars: [{ height, color?, opacity? }] } or just an array.
20
+ */
21
+ update(data) {
22
+ this._data = data;
23
+ this._schedulePaint();
24
+ }
25
+
26
+ /** Generate random bars (for decorative use) */
27
+ randomize() {
28
+ const bars = [];
29
+ for (let i = 0; i < this._count; i++) {
30
+ bars.push({
31
+ height: 12 + Math.random() * 75,
32
+ color: Math.random() > 0.45 ? 'var(--mt-green, #0f0)' : 'var(--mt-red, #f44)',
33
+ });
34
+ }
35
+ this.update(bars);
36
+ }
37
+
38
+ _onData(val) {
39
+ this.update(val);
40
+ }
41
+
42
+ _paint() {
43
+ const pfx = this._pfx;
44
+ let bars = Array.isArray(this._data) ? this._data : (this._data?.bars || []);
45
+ if (!bars.length) {
46
+ bars = [];
47
+ for (let i = 0; i < this._count; i++) {
48
+ bars.push({ height: 12 + Math.random() * 75, color: Math.random() > 0.45 ? 'var(--mt-green, #0f0)' : 'var(--mt-red, #f44)' });
49
+ }
50
+ }
51
+ this._container.innerHTML = `<div class="${pfx}-bars">${bars.map(b =>
52
+ `<div class="${pfx}-bar" style="height:${(b.height || 50).toFixed(0)}%;background:${b.color || 'var(--mt-accent, #0aa)'};${b.opacity != null ? `opacity:${b.opacity}` : ''}"></div>`
53
+ ).join('')}</div>`;
54
+ }
55
+ }
@@ -0,0 +1,49 @@
1
+ /** Base widget — rAF batching + bind + destroy. All widgets extend this. */
2
+
3
+ import { Emitter } from '../emitter.js';
4
+
5
+ export class BaseWidget extends Emitter {
6
+ constructor(container, opts = {}) {
7
+ super();
8
+ this._container = container;
9
+ this._pfx = opts.classPrefix || 'mt';
10
+ this._dirty = false;
11
+ this._rafId = 0;
12
+ }
13
+
14
+ _schedulePaint() {
15
+ if (this._dirty) return;
16
+ this._dirty = true;
17
+ this._rafId = requestAnimationFrame(() => {
18
+ this._dirty = false;
19
+ this._paint();
20
+ });
21
+ }
22
+
23
+ /** Override in subclass */
24
+ _paint() {}
25
+
26
+ bind(adapter, transformKey) {
27
+ adapter.on('data', (d) => {
28
+ const val = transformKey ? d[transformKey] : d;
29
+ this._onData(val);
30
+ });
31
+ }
32
+
33
+ /** Override in subclass to handle adapter data. Default: push scalars, update arrays. */
34
+ _onData(val) {
35
+ if (Array.isArray(val)) this.update(val);
36
+ else this.push(val);
37
+ }
38
+
39
+ /** Override in subclass */
40
+ update() {}
41
+ /** Override in subclass */
42
+ push() {}
43
+
44
+ destroy() {
45
+ if (this._rafId) cancelAnimationFrame(this._rafId);
46
+ this._container.innerHTML = '';
47
+ super.destroy();
48
+ }
49
+ }
@@ -0,0 +1,81 @@
1
+ /** Scrolling message log widget with typed entries and rAF batching */
2
+
3
+ import { BaseWidget } from './base-widget.js';
4
+
5
+ export class MessageLog extends BaseWidget {
6
+ /**
7
+ * @param {HTMLElement} container
8
+ * @param {object} [opts]
9
+ * @param {number} [opts.maxMessages=50] — max visible messages
10
+ * @param {Record<string,string>} [opts.typeClasses] — map of type→CSS class
11
+ * @param {string} [opts.classPrefix='mt']
12
+ */
13
+ constructor(container, opts = {}) {
14
+ super(container, opts);
15
+ this._max = opts.maxMessages ?? 50;
16
+ this._typeClasses = opts.typeClasses || {
17
+ bad: 'mt-red', good: 'mt-green', trade: 'mt-yellow',
18
+ listing: 'mt-cyan', info: '', error: 'mt-red', warn: 'mt-yellow',
19
+ };
20
+ this._messages = [];
21
+ this._fullRepaint = true;
22
+ }
23
+
24
+ /** Replace all messages. Each: { text, type?, timestamp? } or string */
25
+ update(messages) {
26
+ this._messages = messages.slice(0, this._max);
27
+ this._fullRepaint = true;
28
+ this._schedulePaint();
29
+ }
30
+
31
+ /** Push a single message (prepends). Only inserts one DOM node when possible. */
32
+ push(msg) {
33
+ this._messages.unshift(typeof msg === 'string' ? { text: msg } : msg);
34
+ if (this._messages.length > this._max) this._messages.length = this._max;
35
+ this._schedulePaint();
36
+ }
37
+
38
+ _onData(val) {
39
+ if (Array.isArray(val)) this.update(val);
40
+ else this.push(val);
41
+ }
42
+
43
+ _renderMsg(m) {
44
+ const pfx = this._pfx;
45
+ const text = typeof m === 'string' ? m : m.text;
46
+ const type = (typeof m === 'object' && m.type) || 'info';
47
+ const cls = this._typeClasses[type] || '';
48
+ const ts = typeof m === 'object' && m.timestamp != null
49
+ ? `<span class="${pfx}-dim">[${m.timestamp}]</span> `
50
+ : '';
51
+ return `<div class="${pfx}-message">${ts}<span class="${cls}">${text}</span></div>`;
52
+ }
53
+
54
+ _paint() {
55
+ const pfx = this._pfx;
56
+ const msgs = this._messages;
57
+
58
+ if (!msgs.length) {
59
+ this._container.innerHTML = `<span class="${pfx}-dim">No messages.</span>`;
60
+ this._fullRepaint = true;
61
+ return;
62
+ }
63
+
64
+ // Incremental: if only a push happened, insert at top and trim bottom
65
+ if (!this._fullRepaint && this._container.children.length > 0) {
66
+ const newDiv = document.createElement('div');
67
+ newDiv.innerHTML = this._renderMsg(msgs[0]);
68
+ const child = newDiv.firstElementChild;
69
+ if (child) this._container.insertBefore(child, this._container.firstChild);
70
+ // Trim excess
71
+ while (this._container.children.length > this._max) {
72
+ this._container.removeChild(this._container.lastChild);
73
+ }
74
+ return;
75
+ }
76
+
77
+ // Full repaint
78
+ this._fullRepaint = false;
79
+ this._container.innerHTML = msgs.map(m => this._renderMsg(m)).join('');
80
+ }
81
+ }
@@ -0,0 +1,144 @@
1
+ /** ASCII sparkline / line chart widget with rAF batching */
2
+
3
+ import { BaseWidget } from './base-widget.js';
4
+
5
+ const BLOCK = '\u25CF'; // ●
6
+ const VLINE = '\u2502'; // │
7
+
8
+ export class MiniChart extends BaseWidget {
9
+ /**
10
+ * @param {HTMLElement} container
11
+ * @param {object} [opts]
12
+ * @param {number} [opts.width=50] — chart columns
13
+ * @param {number} [opts.height=10] — chart rows
14
+ * @param {number} [opts.maxPoints=500] — max data points kept
15
+ * @param {(v:number)=>string} [opts.formatLabel] — label formatter
16
+ * @param {string} [opts.upClass='mt-green'] — CSS class for up moves
17
+ * @param {string} [opts.downClass='mt-red'] — CSS class for down moves
18
+ * @param {string} [opts.dimClass='mt-dim'] — CSS class for axis
19
+ */
20
+ constructor(container, opts = {}) {
21
+ super(container, opts);
22
+ this._W = opts.width ?? 50;
23
+ this._H = opts.height ?? 10;
24
+ this._maxPoints = opts.maxPoints ?? 500;
25
+ this._fmt = opts.formatLabel || defaultFmt;
26
+ this._upCls = opts.upClass || 'mt-green';
27
+ this._downCls = opts.downClass || 'mt-red';
28
+ this._dimCls = opts.dimClass || 'mt-dim';
29
+ this._data = [];
30
+ // Pre-allocate reusable arrays
31
+ this._yPos = new Array(this._W);
32
+ this._preEl = null;
33
+ }
34
+
35
+ /** Bulk update with array of values */
36
+ update(data) {
37
+ if (Array.isArray(data)) this._data = data.slice(-this._maxPoints);
38
+ else if (data && Array.isArray(data.values)) this._data = data.values.slice(-this._maxPoints);
39
+ this._schedulePaint();
40
+ }
41
+
42
+ /** Push a single value (streaming). Safe to call at 1000+ Hz — rAF batched. */
43
+ push(value) {
44
+ this._data.push(value);
45
+ if (this._data.length > this._maxPoints) this._data.shift();
46
+ this._schedulePaint();
47
+ }
48
+
49
+ _onData(val) {
50
+ if (typeof val === 'number') this.push(val);
51
+ else if (Array.isArray(val)) this.update(val);
52
+ }
53
+
54
+ _paint() {
55
+ const data = this._resample(this._data, this._W);
56
+ if (data.length < 2) { this._container.textContent = ''; return; }
57
+
58
+ let min = data[0], max = data[0];
59
+ for (let i = 1, n = data.length; i < n; i++) {
60
+ if (data[i] < min) min = data[i];
61
+ if (data[i] > max) max = data[i];
62
+ }
63
+ const range = max - min || 1;
64
+ const H = this._H, W = this._W;
65
+ const yPos = this._yPos;
66
+
67
+ // Compute y positions
68
+ for (let i = 0; i < W; i++) yPos[i] = Math.round((1 - (data[i] - min) / range) * (H - 1));
69
+
70
+ // Build grid — flat array for speed
71
+ const grid = new Array(H * W);
72
+ grid.fill(0); // 0 = space, otherwise a string
73
+
74
+ const upCls = this._upCls;
75
+ const downCls = this._downCls;
76
+
77
+ for (let col = 0; col < W; col++) {
78
+ const y = yPos[col];
79
+ const isUp = col > 0 ? data[col] >= data[col - 1] : true;
80
+ const cls = isUp ? upCls : downCls;
81
+ grid[y * W + col] = `<span class="${cls}">${BLOCK}</span>`;
82
+ if (col > 0) {
83
+ const prevY = yPos[col - 1];
84
+ const lo = prevY < y ? prevY : y;
85
+ const hi = prevY > y ? prevY : y;
86
+ for (let r = lo + 1; r < hi; r++) {
87
+ if (grid[r * W + col] === 0) grid[r * W + col] = `<span class="${cls}">${VLINE}</span>`;
88
+ }
89
+ }
90
+ }
91
+
92
+ const topLabel = this._fmt(max);
93
+ const botLabel = this._fmt(min);
94
+ const pw = topLabel.length > botLabel.length ? topLabel.length : botLabel.length;
95
+ const dim = this._dimCls;
96
+
97
+ // Build output lines
98
+ const parts = [];
99
+ for (let r = 0; r < H; r++) {
100
+ let label;
101
+ if (r === 0) label = topLabel.padStart(pw);
102
+ else if (r === H - 1) label = botLabel.padStart(pw);
103
+ else label = ''.padStart(pw);
104
+ parts.push(`<span class="${dim}">${label} \u2502</span>`);
105
+ const rowStart = r * W;
106
+ for (let c = 0; c < W; c++) {
107
+ const cell = grid[rowStart + c];
108
+ parts.push(cell === 0 ? ' ' : cell);
109
+ }
110
+ if (r < H - 1) parts.push('\n');
111
+ }
112
+
113
+ // Reuse <pre> element
114
+ if (!this._preEl || !this._container.contains(this._preEl)) {
115
+ this._preEl = document.createElement('pre');
116
+ this._preEl.className = 'mt-chart-canvas';
117
+ this._container.textContent = '';
118
+ this._container.appendChild(this._preEl);
119
+ }
120
+ this._preEl.innerHTML = parts.join('');
121
+ }
122
+
123
+ _resample(arr, targetLen) {
124
+ const len = arr.length;
125
+ if (len <= 1) return len === 1 ? new Array(targetLen).fill(arr[0]) : [];
126
+ const result = new Array(targetLen);
127
+ const scale = (len - 1) / (targetLen - 1);
128
+ for (let i = 0; i < targetLen; i++) {
129
+ const srcIdx = i * scale;
130
+ const lo = srcIdx | 0; // fast floor
131
+ const hi = lo + 1 < len ? lo + 1 : lo;
132
+ const frac = srcIdx - lo;
133
+ result[i] = arr[lo] + (arr[hi] - arr[lo]) * frac;
134
+ }
135
+ return result;
136
+ }
137
+ }
138
+
139
+ function defaultFmt(v) {
140
+ if (v >= 1000) return v.toFixed(0);
141
+ if (v >= 1) return v.toFixed(2);
142
+ if (v >= 0.01) return v.toFixed(4);
143
+ return String(v);
144
+ }
@@ -0,0 +1,94 @@
1
+ /** Horizontal range bar with markers, zones, and interactive ghost hover */
2
+
3
+ import { BaseWidget } from './base-widget.js';
4
+
5
+ export class RangeBar extends BaseWidget {
6
+ /**
7
+ * @param {HTMLElement} container
8
+ * @param {object} [opts]
9
+ * @param {string} [opts.classPrefix='mt']
10
+ * @param {(v:number)=>string} [opts.formatLabel] — value formatter
11
+ */
12
+ constructor(container, opts = {}) {
13
+ super(container, opts);
14
+ this._fmt = opts.formatLabel || ((v) => v.toFixed(2));
15
+ this._data = null;
16
+ this._ghostBound = false;
17
+ }
18
+
19
+ /**
20
+ * Update the bar.
21
+ * @param {object} data
22
+ * @param {number} data.lo — range minimum
23
+ * @param {number} data.hi — range maximum
24
+ * @param {Array} [data.markers] — [{ position, label, type?, className? }]
25
+ * @param {Array} [data.zones] — [{ from, to, className }]
26
+ */
27
+ update(data) {
28
+ this._data = data;
29
+ this._schedulePaint();
30
+ }
31
+
32
+ _onData(val) {
33
+ if (val && typeof val === 'object') this.update(val);
34
+ }
35
+
36
+ _paint() {
37
+ const d = this._data;
38
+ if (!d) return;
39
+ const pfx = this._pfx;
40
+ const { lo, hi, markers = [], zones = [] } = d;
41
+ const range = hi - lo || 1;
42
+ const pct = (v) => ((v - lo) / range * 100).toFixed(1);
43
+
44
+ let html = `<div class="${pfx}-pb" data-pb-lo="${lo}" data-pb-hi="${hi}">`;
45
+ html += `<div class="${pfx}-pb-track">`;
46
+
47
+ for (let i = 0; i < zones.length; i++) {
48
+ const z = zones[i];
49
+ const left = pct(z.from);
50
+ const width = (((z.to - z.from) / range) * 100).toFixed(1);
51
+ html += `<div class="${pfx}-pb-fill ${z.className || ''}" style="left:${left}%;width:${width}%"></div>`;
52
+ }
53
+
54
+ for (let i = 0; i < markers.length; i++) {
55
+ const m = markers[i];
56
+ const left = pct(m.position);
57
+ const cls = m.className || `${pfx}-pb-${m.type || 'default'}`;
58
+ html += `<div class="${pfx}-pb-marker ${cls}" style="left:${left}%"><span class="${pfx}-pb-line"></span><span class="${pfx}-pb-label">${m.label || ''}</span></div>`;
59
+ }
60
+
61
+ html += `<div class="${pfx}-pb-ghost" style="display:none"><span class="${pfx}-pb-ghost-line"></span><span class="${pfx}-pb-ghost-label"></span></div>`;
62
+ html += `</div></div>`;
63
+ this._container.innerHTML = html;
64
+ this._ghostBound = false;
65
+ this._bindGhost();
66
+ }
67
+
68
+ _bindGhost() {
69
+ if (this._ghostBound) return;
70
+ const pfx = this._pfx;
71
+ const track = this._container.querySelector(`.${pfx}-pb-track`);
72
+ const ghost = this._container.querySelector(`.${pfx}-pb-ghost`);
73
+ const bar = this._container.querySelector(`.${pfx}-pb`);
74
+ if (!track || !ghost || !bar) return;
75
+ this._ghostBound = true;
76
+
77
+ const fmt = this._fmt;
78
+ const self = this;
79
+
80
+ track.addEventListener('mousemove', (e) => {
81
+ const rect = track.getBoundingClientRect();
82
+ const xPct = ((e.clientX - rect.left) / rect.width) * 100;
83
+ const clamped = xPct < 0 ? 0 : xPct > 100 ? 100 : xPct;
84
+ const lo = parseFloat(bar.dataset.pbLo);
85
+ const hi = parseFloat(bar.dataset.pbHi);
86
+ const hoverValue = lo + (clamped / 100) * (hi - lo);
87
+ ghost.style.display = '';
88
+ ghost.style.transform = `translateX(${(clamped / 100 * rect.width).toFixed(1)}px)`;
89
+ ghost.querySelector(`.${pfx}-pb-ghost-label`).textContent = fmt(hoverValue);
90
+ self.emit('hover', { value: hoverValue, pct: clamped });
91
+ });
92
+ track.addEventListener('mouseleave', () => { ghost.style.display = 'none'; });
93
+ }
94
+ }
@@ -0,0 +1,53 @@
1
+ /** Scrolling ticker tape widget — CSS-driven animation */
2
+
3
+ import { BaseWidget } from './base-widget.js';
4
+
5
+ export class Ticker extends BaseWidget {
6
+ /**
7
+ * @param {HTMLElement} container — mount point
8
+ * @param {object} [opts]
9
+ * @param {number} [opts.baseDuration=8] — seconds for a full scroll cycle
10
+ * @param {number} [opts.durationPerItem=2] — added seconds per item
11
+ * @param {string} [opts.classPrefix='mt'] — CSS class prefix
12
+ */
13
+ constructor(container, opts = {}) {
14
+ super(container, opts);
15
+ this._baseDuration = opts.baseDuration ?? 8;
16
+ this._durationPerItem = opts.durationPerItem ?? 2;
17
+ this._items = [];
18
+ this._lastItemCount = -1;
19
+ }
20
+
21
+ /** Replace all ticker items. Each item: { label, value, className? } */
22
+ update(items) {
23
+ this._items = items;
24
+ this._schedulePaint();
25
+ }
26
+
27
+ /** Push a single item (appends) */
28
+ push(item) {
29
+ this._items.push(item);
30
+ this._schedulePaint();
31
+ }
32
+
33
+ _paint() {
34
+ const pfx = this._pfx;
35
+ const items = this._items;
36
+ if (!items.length) {
37
+ this._container.innerHTML = '';
38
+ return;
39
+ }
40
+ const inner = items.map(it =>
41
+ `<span class="${pfx}-ticker-item">${it.label ? `<span class="${pfx}-ticker-label">${it.label}</span> ` : ''}${it.value ? `<span class="${it.className || ''}">${it.value}</span>` : ''}</span>`
42
+ ).join('');
43
+ const duration = Math.max(this._baseDuration, items.length * this._durationPerItem);
44
+ // Only rebuild DOM when item count changes; update track content otherwise
45
+ if (items.length !== this._lastItemCount) {
46
+ this._container.innerHTML = `<div class="${pfx}-ticker-wrap" style="--ticker-duration:${duration}s"><div class="${pfx}-ticker-track">${inner}${inner}</div></div>`;
47
+ this._lastItemCount = items.length;
48
+ } else {
49
+ const track = this._container.querySelector(`.${pfx}-ticker-track`);
50
+ if (track) track.innerHTML = inner + inner;
51
+ }
52
+ }
53
+ }