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/LICENSE +24 -0
- package/README.md +194 -0
- package/css/minterm.css +400 -0
- package/index.js +27 -0
- package/package.json +40 -0
- package/src/adapters/base-adapter.js +59 -0
- package/src/adapters/manual-adapter.js +14 -0
- package/src/adapters/nats-adapter.js +42 -0
- package/src/adapters/redis-adapter.js +83 -0
- package/src/adapters/rest-poller.js +42 -0
- package/src/adapters/websocket-adapter.js +59 -0
- package/src/arrow-overlay.js +250 -0
- package/src/emitter.js +30 -0
- package/src/formatters.js +41 -0
- package/src/modal-manager.js +70 -0
- package/src/themes.js +149 -0
- package/src/widgets/activity-bars.js +55 -0
- package/src/widgets/base-widget.js +49 -0
- package/src/widgets/message-log.js +81 -0
- package/src/widgets/mini-chart.js +144 -0
- package/src/widgets/range-bar.js +94 -0
- package/src/widgets/ticker.js +53 -0
- package/src/window-manager.js +425 -0
- package/src/z-index.js +35 -0
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
|
+
}
|