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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Base data adapter — all adapters extend this */
|
|
2
|
+
|
|
3
|
+
import { Emitter } from '../emitter.js';
|
|
4
|
+
|
|
5
|
+
export class BaseAdapter extends Emitter {
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} [opts]
|
|
8
|
+
* @param {number} [opts.throttle=0] — min ms between emissions (0=no throttle)
|
|
9
|
+
* @param {(raw:any)=>any} [opts.transform] — transform raw data before emitting
|
|
10
|
+
*/
|
|
11
|
+
constructor(opts = {}) {
|
|
12
|
+
super();
|
|
13
|
+
this._throttle = opts.throttle || 0;
|
|
14
|
+
this._transform = opts.transform || null;
|
|
15
|
+
this._lastEmit = -Infinity;
|
|
16
|
+
this._pending = null;
|
|
17
|
+
this._throttleTimer = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Subclasses call this to emit data through the throttle/transform pipeline */
|
|
21
|
+
_emitData(raw) {
|
|
22
|
+
const data = this._transform ? this._transform(raw) : raw;
|
|
23
|
+
if (data == null) return;
|
|
24
|
+
|
|
25
|
+
if (this._throttle <= 0) {
|
|
26
|
+
this.emit('data', data);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const now = performance.now();
|
|
31
|
+
const elapsed = now - this._lastEmit;
|
|
32
|
+
if (elapsed >= this._throttle) {
|
|
33
|
+
this._lastEmit = now;
|
|
34
|
+
this.emit('data', data);
|
|
35
|
+
} else {
|
|
36
|
+
// Keep latest, drop intermediates
|
|
37
|
+
this._pending = data;
|
|
38
|
+
if (!this._throttleTimer) {
|
|
39
|
+
this._throttleTimer = setTimeout(() => {
|
|
40
|
+
this._throttleTimer = null;
|
|
41
|
+
if (this._pending != null) {
|
|
42
|
+
this._lastEmit = performance.now();
|
|
43
|
+
this.emit('data', this._pending);
|
|
44
|
+
this._pending = null;
|
|
45
|
+
}
|
|
46
|
+
}, this._throttle - elapsed);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Override in subclass */
|
|
52
|
+
connect() {}
|
|
53
|
+
|
|
54
|
+
/** Override in subclass */
|
|
55
|
+
disconnect() {
|
|
56
|
+
if (this._throttleTimer) clearTimeout(this._throttleTimer);
|
|
57
|
+
this._pending = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Manual push adapter — call .push(data) to emit */
|
|
2
|
+
|
|
3
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
4
|
+
|
|
5
|
+
export class ManualAdapter extends BaseAdapter {
|
|
6
|
+
constructor(opts = {}) {
|
|
7
|
+
super(opts);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Push data through the adapter pipeline */
|
|
11
|
+
push(data) {
|
|
12
|
+
this._emitData(data);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** NATS adapter — requires nats.ws at runtime */
|
|
2
|
+
|
|
3
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
4
|
+
|
|
5
|
+
export class NatsAdapter extends BaseAdapter {
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.servers — NATS server URL (e.g. 'wss://nats.example.com')
|
|
9
|
+
* @param {string} opts.subject — NATS subject to subscribe to
|
|
10
|
+
* @param {object} [opts.natsConnect] — the `connect` function from nats.ws (injected to avoid hard dependency)
|
|
11
|
+
* @param {number} [opts.throttle=0]
|
|
12
|
+
* @param {(raw:any)=>any} [opts.transform]
|
|
13
|
+
*/
|
|
14
|
+
constructor(opts = {}) {
|
|
15
|
+
super(opts);
|
|
16
|
+
this._servers = opts.servers;
|
|
17
|
+
this._subject = opts.subject;
|
|
18
|
+
this._natsConnect = opts.natsConnect;
|
|
19
|
+
this._nc = null;
|
|
20
|
+
this._sub = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async connect() {
|
|
24
|
+
if (!this._natsConnect) throw new Error('NatsAdapter requires opts.natsConnect (from nats.ws)');
|
|
25
|
+
this._nc = await this._natsConnect({ servers: this._servers });
|
|
26
|
+
this.emit('open');
|
|
27
|
+
this._sub = this._nc.subscribe(this._subject);
|
|
28
|
+
(async () => {
|
|
29
|
+
for await (const msg of this._sub) {
|
|
30
|
+
let data;
|
|
31
|
+
try { data = JSON.parse(msg.data); } catch { data = msg.string(); }
|
|
32
|
+
this._emitData(data);
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async disconnect() {
|
|
38
|
+
if (this._sub) { this._sub.unsubscribe(); this._sub = null; }
|
|
39
|
+
if (this._nc) { await this._nc.close(); this._nc = null; }
|
|
40
|
+
super.disconnect();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Redis Pub/Sub adapter — requires a redis client at runtime */
|
|
2
|
+
|
|
3
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
4
|
+
|
|
5
|
+
export class RedisAdapter extends BaseAdapter {
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {object} opts.client — a redis client instance (e.g. from 'redis' or 'ioredis')
|
|
9
|
+
* @param {string|string[]} opts.channels — channel(s) to subscribe to
|
|
10
|
+
* @param {string|string[]} [opts.patterns] — pattern(s) to psubscribe to (e.g. 'sensor:*')
|
|
11
|
+
* @param {number} [opts.throttle=0]
|
|
12
|
+
* @param {(raw:any)=>any} [opts.transform]
|
|
13
|
+
*/
|
|
14
|
+
constructor(opts = {}) {
|
|
15
|
+
super(opts);
|
|
16
|
+
this._client = opts.client;
|
|
17
|
+
this._channels = opts.channels ? [].concat(opts.channels) : [];
|
|
18
|
+
this._patterns = opts.patterns ? [].concat(opts.patterns) : [];
|
|
19
|
+
this._sub = null;
|
|
20
|
+
this._handler = (msg, channel) => {
|
|
21
|
+
let data;
|
|
22
|
+
try { data = JSON.parse(msg); } catch { data = msg; }
|
|
23
|
+
this._emitData({ channel, data });
|
|
24
|
+
};
|
|
25
|
+
this._pHandler = (msg, channel, pattern) => {
|
|
26
|
+
let data;
|
|
27
|
+
try { data = JSON.parse(msg); } catch { data = msg; }
|
|
28
|
+
this._emitData({ channel, pattern, data });
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async connect() {
|
|
33
|
+
if (!this._client) throw new Error('RedisAdapter requires opts.client (redis/ioredis instance)');
|
|
34
|
+
|
|
35
|
+
// ioredis: client.duplicate() exists; node-redis: client.duplicate() exists
|
|
36
|
+
// We need a dedicated subscriber since redis clients in subscribe mode can't run other commands
|
|
37
|
+
if (typeof this._client.duplicate === 'function') {
|
|
38
|
+
this._sub = this._client.duplicate();
|
|
39
|
+
} else {
|
|
40
|
+
this._sub = this._client;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// node-redis v4 requires explicit connect
|
|
44
|
+
if (typeof this._sub.connect === 'function' && !this._sub.isOpen) {
|
|
45
|
+
await this._sub.connect();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Detect API style: ioredis uses .on('message'), node-redis v4 uses .subscribe(channel, callback)
|
|
49
|
+
if (typeof this._sub.on === 'function' && typeof this._sub.subscribe === 'function') {
|
|
50
|
+
// ioredis style
|
|
51
|
+
if (this._channels.length) {
|
|
52
|
+
this._sub.on('message', this._handler);
|
|
53
|
+
await this._sub.subscribe(...this._channels);
|
|
54
|
+
}
|
|
55
|
+
if (this._patterns.length) {
|
|
56
|
+
this._sub.on('pmessage', this._pHandler);
|
|
57
|
+
await this._sub.psubscribe(...this._patterns);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.emit('open');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async disconnect() {
|
|
65
|
+
if (this._sub) {
|
|
66
|
+
if (this._channels.length && typeof this._sub.unsubscribe === 'function') {
|
|
67
|
+
await this._sub.unsubscribe(...this._channels);
|
|
68
|
+
}
|
|
69
|
+
if (this._patterns.length && typeof this._sub.punsubscribe === 'function') {
|
|
70
|
+
await this._sub.punsubscribe(...this._patterns);
|
|
71
|
+
}
|
|
72
|
+
if (typeof this._sub.removeListener === 'function') {
|
|
73
|
+
this._sub.removeListener('message', this._handler);
|
|
74
|
+
this._sub.removeListener('pmessage', this._pHandler);
|
|
75
|
+
}
|
|
76
|
+
if (typeof this._sub.quit === 'function') {
|
|
77
|
+
await this._sub.quit();
|
|
78
|
+
}
|
|
79
|
+
this._sub = null;
|
|
80
|
+
}
|
|
81
|
+
super.disconnect();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** REST polling adapter — fetches a URL on an interval */
|
|
2
|
+
|
|
3
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
4
|
+
|
|
5
|
+
export class RestPoller extends BaseAdapter {
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} url
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {number} [opts.interval=5000] — polling interval ms
|
|
10
|
+
* @param {RequestInit} [opts.fetchOpts] — passed to fetch()
|
|
11
|
+
* @param {number} [opts.throttle=0]
|
|
12
|
+
* @param {(raw:any)=>any} [opts.transform]
|
|
13
|
+
*/
|
|
14
|
+
constructor(url, opts = {}) {
|
|
15
|
+
super(opts);
|
|
16
|
+
this._url = url;
|
|
17
|
+
this._interval = opts.interval ?? 5000;
|
|
18
|
+
this._fetchOpts = opts.fetchOpts || {};
|
|
19
|
+
this._timer = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
connect() {
|
|
23
|
+
this._poll();
|
|
24
|
+
this._timer = setInterval(() => this._poll(), this._interval);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
disconnect() {
|
|
28
|
+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
29
|
+
super.disconnect();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async _poll() {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(this._url, this._fetchOpts);
|
|
35
|
+
if (!res.ok) { this.emit('error', new Error(`HTTP ${res.status}`)); return; }
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
this._emitData(data);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
this.emit('error', err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** WebSocket data adapter with auto-reconnect */
|
|
2
|
+
|
|
3
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
4
|
+
|
|
5
|
+
export class WebSocketAdapter extends BaseAdapter {
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} url — WebSocket URL
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {boolean} [opts.reconnect=true] — auto-reconnect on close
|
|
10
|
+
* @param {number} [opts.reconnectDelay=3000] — ms before reconnect
|
|
11
|
+
* @param {number} [opts.throttle=0]
|
|
12
|
+
* @param {(raw:any)=>any} [opts.transform]
|
|
13
|
+
*/
|
|
14
|
+
constructor(url, opts = {}) {
|
|
15
|
+
super(opts);
|
|
16
|
+
this._url = url;
|
|
17
|
+
this._reconnect = opts.reconnect !== false;
|
|
18
|
+
this._reconnectDelay = opts.reconnectDelay ?? 3000;
|
|
19
|
+
this._ws = null;
|
|
20
|
+
this._closing = false;
|
|
21
|
+
this._reconnectTimer = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
connect() {
|
|
25
|
+
this._closing = false;
|
|
26
|
+
this._ws = new WebSocket(this._url);
|
|
27
|
+
|
|
28
|
+
this._ws.onopen = () => this.emit('open');
|
|
29
|
+
|
|
30
|
+
this._ws.onmessage = (e) => {
|
|
31
|
+
let data;
|
|
32
|
+
try { data = JSON.parse(e.data); } catch { data = e.data; }
|
|
33
|
+
this._emitData(data);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
this._ws.onclose = () => {
|
|
37
|
+
this.emit('close');
|
|
38
|
+
if (this._reconnect && !this._closing) {
|
|
39
|
+
this._reconnectTimer = setTimeout(() => this.connect(), this._reconnectDelay);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
this._ws.onerror = (err) => this.emit('error', err);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
disconnect() {
|
|
47
|
+
this._closing = true;
|
|
48
|
+
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
|
49
|
+
if (this._ws) { this._ws.close(); this._ws = null; }
|
|
50
|
+
super.disconnect();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Send data to the WebSocket */
|
|
54
|
+
send(data) {
|
|
55
|
+
if (this._ws?.readyState === WebSocket.OPEN) {
|
|
56
|
+
this._ws.send(typeof data === 'string' ? data : JSON.stringify(data));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/** SVG arrow overlay — draws arrows between windows, auto-updates on drag/focus */
|
|
2
|
+
|
|
3
|
+
import { Emitter } from './emitter.js';
|
|
4
|
+
|
|
5
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
6
|
+
|
|
7
|
+
export class ArrowOverlay extends Emitter {
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('./window-manager.js').WindowManager} wm
|
|
10
|
+
* @param {object} [opts]
|
|
11
|
+
* @param {string} [opts.classPrefix='mt']
|
|
12
|
+
* @param {string} [opts.defaultColor='#0ff']
|
|
13
|
+
*/
|
|
14
|
+
constructor(wm, opts = {}) {
|
|
15
|
+
super();
|
|
16
|
+
this._wm = wm;
|
|
17
|
+
this._pfx = opts.classPrefix || 'mt';
|
|
18
|
+
this._defaultColor = opts.defaultColor || '#0ff';
|
|
19
|
+
this._arrows = {}; // id → arrow config
|
|
20
|
+
this._groups = {}; // id → { g, line, dot, glow, text } — cached SVG nodes
|
|
21
|
+
this._svg = null;
|
|
22
|
+
this._dirty = false;
|
|
23
|
+
this._rafId = 0;
|
|
24
|
+
|
|
25
|
+
const repaint = () => this._schedulePaint();
|
|
26
|
+
wm.on('window:move', repaint);
|
|
27
|
+
wm.on('window:resize', repaint);
|
|
28
|
+
wm.on('window:focus', ({ id, el }) => this._raiseArrowsForWindow(id, el));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Set or update an arrow.
|
|
33
|
+
* @param {string} id — unique arrow id
|
|
34
|
+
* @param {object} arrow
|
|
35
|
+
* @param {string} arrow.fromWindow — source window id
|
|
36
|
+
* @param {string} arrow.toWindow — target window id
|
|
37
|
+
* @param {number} [arrow.progress=0] — 0-1 position of traveling dot
|
|
38
|
+
* @param {string} [arrow.label] — text label at midpoint
|
|
39
|
+
* @param {string} [arrow.color] — arrow color
|
|
40
|
+
*/
|
|
41
|
+
setArrow(id, arrow) {
|
|
42
|
+
this._arrows[id] = arrow;
|
|
43
|
+
this._schedulePaint();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
removeArrow(id) {
|
|
47
|
+
delete this._arrows[id];
|
|
48
|
+
const cached = this._groups[id];
|
|
49
|
+
if (cached) { cached.g.remove(); delete this._groups[id]; }
|
|
50
|
+
this._schedulePaint();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
clearArrows() {
|
|
54
|
+
this._arrows = {};
|
|
55
|
+
for (const id in this._groups) { this._groups[id].g.remove(); }
|
|
56
|
+
this._groups = {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_ensureSvg() {
|
|
60
|
+
if (this._svg) return this._svg;
|
|
61
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
62
|
+
svg.setAttribute('class', `${this._pfx}-arrow-svg`);
|
|
63
|
+
svg.innerHTML = '<defs></defs>';
|
|
64
|
+
document.body.appendChild(svg);
|
|
65
|
+
this._svg = svg;
|
|
66
|
+
return svg;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_getOrCreateMarker(color) {
|
|
70
|
+
const svg = this._ensureSvg();
|
|
71
|
+
const defs = svg.querySelector('defs');
|
|
72
|
+
const markerId = `${this._pfx}-ah-${color.replace('#', '')}`;
|
|
73
|
+
if (!defs.querySelector(`#${markerId}`)) {
|
|
74
|
+
const marker = document.createElementNS(SVG_NS, 'marker');
|
|
75
|
+
marker.id = markerId;
|
|
76
|
+
setAttrs(marker, { markerWidth: '8', markerHeight: '6', refX: '7', refY: '3', orient: 'auto' });
|
|
77
|
+
const poly = document.createElementNS(SVG_NS, 'polygon');
|
|
78
|
+
setAttrs(poly, { points: '0 0, 8 3, 0 6', fill: color, opacity: '0.8' });
|
|
79
|
+
marker.appendChild(poly);
|
|
80
|
+
defs.appendChild(marker);
|
|
81
|
+
}
|
|
82
|
+
return markerId;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_schedulePaint() {
|
|
86
|
+
if (this._dirty) return;
|
|
87
|
+
this._dirty = true;
|
|
88
|
+
this._rafId = requestAnimationFrame(() => {
|
|
89
|
+
this._dirty = false;
|
|
90
|
+
this._paint();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_paint() {
|
|
95
|
+
const svg = this._ensureSvg();
|
|
96
|
+
const pfx = this._pfx;
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
|
|
99
|
+
for (const id in this._arrows) {
|
|
100
|
+
seen.add(id);
|
|
101
|
+
const arrow = this._arrows[id];
|
|
102
|
+
const fromEl = this._wm.getWindow(arrow.fromWindow);
|
|
103
|
+
const toEl = this._wm.getWindow(arrow.toWindow);
|
|
104
|
+
if (!fromEl || !toEl || fromEl === toEl) {
|
|
105
|
+
if (this._groups[id]) { this._groups[id].g.style.display = 'none'; }
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fromRect = fromEl.getBoundingClientRect();
|
|
110
|
+
const toRect = toEl.getBoundingClientRect();
|
|
111
|
+
const fromR = { x: fromRect.left, y: fromRect.top, w: fromRect.width, h: fromRect.height };
|
|
112
|
+
const toR = { x: toRect.left, y: toRect.top, w: toRect.width, h: toRect.height };
|
|
113
|
+
const pts = getEdgePoints(fromR, toR);
|
|
114
|
+
const color = arrow.color || this._defaultColor;
|
|
115
|
+
const markerId = this._getOrCreateMarker(color);
|
|
116
|
+
const progress = arrow.progress ?? 0;
|
|
117
|
+
const dotX = pts.x1 + (pts.x2 - pts.x1) * progress;
|
|
118
|
+
const dotY = pts.y1 + (pts.y2 - pts.y1) * progress;
|
|
119
|
+
|
|
120
|
+
let cached = this._groups[id];
|
|
121
|
+
if (!cached) {
|
|
122
|
+
// Create group with all children once
|
|
123
|
+
const g = document.createElementNS(SVG_NS, 'g');
|
|
124
|
+
g.setAttribute('class', `${pfx}-arrow-group`);
|
|
125
|
+
g.dataset.arrowId = id;
|
|
126
|
+
const line = document.createElementNS(SVG_NS, 'line');
|
|
127
|
+
line.setAttribute('class', `${pfx}-arrow-line`);
|
|
128
|
+
setAttrs(line, { 'stroke-width': '1.5', 'stroke-dasharray': '3 5', opacity: '0.6' });
|
|
129
|
+
const glow = document.createElementNS(SVG_NS, 'circle');
|
|
130
|
+
setAttrs(glow, { r: '7', opacity: '0.15' });
|
|
131
|
+
glow.setAttribute('class', `${pfx}-arrow-glow`);
|
|
132
|
+
const dot = document.createElementNS(SVG_NS, 'circle');
|
|
133
|
+
setAttrs(dot, { r: '3.5' });
|
|
134
|
+
dot.setAttribute('class', `${pfx}-arrow-dot`);
|
|
135
|
+
const text = document.createElementNS(SVG_NS, 'text');
|
|
136
|
+
text.setAttribute('class', `${pfx}-arrow-label`);
|
|
137
|
+
g.appendChild(line);
|
|
138
|
+
g.appendChild(glow);
|
|
139
|
+
g.appendChild(dot);
|
|
140
|
+
g.appendChild(text);
|
|
141
|
+
svg.appendChild(g);
|
|
142
|
+
cached = { g, line, dot, glow, text };
|
|
143
|
+
this._groups[id] = cached;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Update attributes (no DOM structure changes)
|
|
147
|
+
cached.g.style.display = '';
|
|
148
|
+
cached.g.dataset.arrowFrom = arrow.fromWindow;
|
|
149
|
+
cached.g.dataset.arrowTo = arrow.toWindow;
|
|
150
|
+
|
|
151
|
+
setAttrs(cached.line, {
|
|
152
|
+
x1: pts.x1, y1: pts.y1, x2: pts.x2, y2: pts.y2,
|
|
153
|
+
stroke: color, 'marker-end': `url(#${markerId})`,
|
|
154
|
+
});
|
|
155
|
+
setAttrs(cached.dot, { cx: dotX, cy: dotY, fill: color });
|
|
156
|
+
setAttrs(cached.glow, { cx: dotX, cy: dotY, fill: color });
|
|
157
|
+
|
|
158
|
+
if (arrow.label) {
|
|
159
|
+
cached.text.style.display = '';
|
|
160
|
+
const midX = (pts.x1 + pts.x2) / 2;
|
|
161
|
+
const midY = (pts.y1 + pts.y2) / 2 - 8;
|
|
162
|
+
setAttrs(cached.text, { x: midX, y: midY, fill: color });
|
|
163
|
+
cached.text.textContent = arrow.label;
|
|
164
|
+
} else {
|
|
165
|
+
cached.text.style.display = 'none';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Remove stale groups
|
|
170
|
+
for (const id in this._groups) {
|
|
171
|
+
if (!seen.has(id)) {
|
|
172
|
+
this._groups[id].g.remove();
|
|
173
|
+
delete this._groups[id];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_raiseArrowsForWindow(winId, winEl) {
|
|
179
|
+
if (!this._svg) return;
|
|
180
|
+
const pfx = this._pfx;
|
|
181
|
+
let hasMatch = false;
|
|
182
|
+
for (const g of this._svg.querySelectorAll(`.${pfx}-arrow-group`)) {
|
|
183
|
+
if (g.dataset.arrowFrom === winId || g.dataset.arrowTo === winId) {
|
|
184
|
+
this._svg.appendChild(g);
|
|
185
|
+
hasMatch = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (hasMatch && winEl) {
|
|
189
|
+
this._svg.style.zIndex = parseInt(winEl.style.zIndex || 0) + 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
destroy() {
|
|
194
|
+
if (this._rafId) cancelAnimationFrame(this._rafId);
|
|
195
|
+
if (this._svg) { this._svg.remove(); this._svg = null; }
|
|
196
|
+
this._arrows = {};
|
|
197
|
+
this._groups = {};
|
|
198
|
+
super.destroy();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function setAttrs(el, attrs) {
|
|
203
|
+
for (const k in attrs) el.setAttribute(k, attrs[k]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Compute edge-to-edge connection points between two rects */
|
|
207
|
+
function getEdgePoints(fromR, toR) {
|
|
208
|
+
const fromCx = fromR.x + fromR.w / 2, fromCy = fromR.y + fromR.h / 2;
|
|
209
|
+
const toCx = toR.x + toR.w / 2, toCy = toR.y + toR.h / 2;
|
|
210
|
+
|
|
211
|
+
const gap = 6;
|
|
212
|
+
const hOverlap = fromR.x < toR.x + toR.w + gap && toR.x < fromR.x + fromR.w + gap;
|
|
213
|
+
const vOverlap = fromR.y < toR.y + toR.h + gap && toR.y < fromR.y + fromR.h + gap;
|
|
214
|
+
|
|
215
|
+
if (hOverlap && vOverlap) {
|
|
216
|
+
const touchBottom = Math.abs((fromR.y + fromR.h) - toR.y) < gap;
|
|
217
|
+
const touchTop = Math.abs(fromR.y - (toR.y + toR.h)) < gap;
|
|
218
|
+
const touchRight = Math.abs((fromR.x + fromR.w) - toR.x) < gap;
|
|
219
|
+
const touchLeft = Math.abs(fromR.x - (toR.x + toR.w)) < gap;
|
|
220
|
+
|
|
221
|
+
if (touchBottom || touchTop) {
|
|
222
|
+
if (fromCx <= toCx) return { x1: fromR.x, y1: fromCy, x2: toR.x, y2: toCy };
|
|
223
|
+
return { x1: fromR.x + fromR.w, y1: fromCy, x2: toR.x + toR.w, y2: toCy };
|
|
224
|
+
}
|
|
225
|
+
if (touchRight || touchLeft) {
|
|
226
|
+
if (fromCy <= toCy) return { x1: fromCx, y1: fromR.y, x2: toCx, y2: toR.y };
|
|
227
|
+
return { x1: fromCx, y1: fromR.y + fromR.h, x2: toCx, y2: toR.y + toR.h };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const angle = Math.atan2(toCy - fromCy, toCx - fromCx);
|
|
232
|
+
const cos = Math.cos(angle), sin = Math.sin(angle);
|
|
233
|
+
|
|
234
|
+
const fhw = fromR.w / 2, fhh = fromR.h / 2;
|
|
235
|
+
const fs = Math.min(
|
|
236
|
+
cos !== 0 ? fhw / Math.abs(cos) : Infinity,
|
|
237
|
+
sin !== 0 ? fhh / Math.abs(sin) : Infinity
|
|
238
|
+
);
|
|
239
|
+
const x1 = fromCx + cos * fs, y1 = fromCy + sin * fs;
|
|
240
|
+
|
|
241
|
+
const cos2 = -cos, sin2 = -sin;
|
|
242
|
+
const thw = toR.w / 2, thh = toR.h / 2;
|
|
243
|
+
const ts = Math.min(
|
|
244
|
+
cos2 !== 0 ? thw / Math.abs(cos2) : Infinity,
|
|
245
|
+
sin2 !== 0 ? thh / Math.abs(sin2) : Infinity
|
|
246
|
+
);
|
|
247
|
+
const x2 = toCx + cos2 * ts, y2 = toCy + sin2 * ts;
|
|
248
|
+
|
|
249
|
+
return { x1, y1, x2, y2 };
|
|
250
|
+
}
|
package/src/emitter.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Tiny event emitter — on/off/emit/once/destroy */
|
|
2
|
+
export class Emitter {
|
|
3
|
+
constructor() { this._e = Object.create(null); }
|
|
4
|
+
|
|
5
|
+
on(evt, fn) {
|
|
6
|
+
(this._e[evt] || (this._e[evt] = [])).push(fn);
|
|
7
|
+
return this;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
off(evt, fn) {
|
|
11
|
+
const a = this._e[evt];
|
|
12
|
+
if (a) this._e[evt] = a.filter(f => f !== fn && f._w !== fn);
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
emit(evt, a, b) {
|
|
17
|
+
const list = this._e[evt];
|
|
18
|
+
if (!list) return this;
|
|
19
|
+
for (let i = 0, n = list.length; i < n; i++) list[i](a, b);
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
once(evt, fn) {
|
|
24
|
+
const w = (a, b) => { this.off(evt, w); fn(a, b); };
|
|
25
|
+
w._w = fn;
|
|
26
|
+
return this.on(evt, w);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
destroy() { this._e = Object.create(null); }
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Formatting helpers for numbers, quantities, and percentages */
|
|
2
|
+
|
|
3
|
+
export function formatNum(n) {
|
|
4
|
+
if (n == null || isNaN(n)) return '0';
|
|
5
|
+
const sign = n < 0 ? '-' : '';
|
|
6
|
+
const a = Math.abs(n);
|
|
7
|
+
if (a >= 1e9) return sign + (a / 1e9).toFixed(a >= 1e10 ? 1 : 2) + 'bil';
|
|
8
|
+
if (a >= 1e6) return sign + (a / 1e6).toFixed(a >= 1e7 ? 1 : 2) + 'mil';
|
|
9
|
+
if (a >= 10000) return sign + (a / 1e3).toFixed(a >= 1e5 ? 0 : 1) + 'k';
|
|
10
|
+
if (a >= 1000) return sign + a.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
11
|
+
if (a >= 1) return n.toFixed(2);
|
|
12
|
+
if (a >= 0.01) return n.toFixed(4);
|
|
13
|
+
if (a > 0) {
|
|
14
|
+
const digits = Math.max(4, -Math.floor(Math.log10(a)) + 2);
|
|
15
|
+
return n.toFixed(Math.min(digits, 12));
|
|
16
|
+
}
|
|
17
|
+
return '0';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @deprecated Use formatNum instead */
|
|
21
|
+
export const formatPrice = formatNum;
|
|
22
|
+
|
|
23
|
+
export function formatQty(q) {
|
|
24
|
+
if (q == null || isNaN(q)) return '0';
|
|
25
|
+
const a = Math.abs(q);
|
|
26
|
+
if (a >= 10000) return q.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
|
27
|
+
if (a >= 100) return q.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
28
|
+
if (a >= 1) return q.toFixed(2);
|
|
29
|
+
if (a >= 0.01) return q.toFixed(4);
|
|
30
|
+
if (a > 0) {
|
|
31
|
+
const digits = Math.max(4, -Math.floor(Math.log10(a)) + 2);
|
|
32
|
+
return q.toFixed(Math.min(digits, 12));
|
|
33
|
+
}
|
|
34
|
+
return '0';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatPct(pct) {
|
|
38
|
+
if (pct == null || isNaN(pct)) return '0%';
|
|
39
|
+
const sign = pct > 0 ? '+' : '';
|
|
40
|
+
return `${sign}${pct.toFixed(1)}%`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Modal dialog manager — wraps WindowManager */
|
|
2
|
+
|
|
3
|
+
export class ModalManager {
|
|
4
|
+
/**
|
|
5
|
+
* @param {import('./window-manager.js').WindowManager} wm
|
|
6
|
+
*/
|
|
7
|
+
constructor(wm) {
|
|
8
|
+
this._wm = wm;
|
|
9
|
+
this._pfx = wm._pfx || 'mt';
|
|
10
|
+
this._id = `${this._pfx}-modal`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
show(html, opts = {}) {
|
|
14
|
+
this.hide();
|
|
15
|
+
const { width = 380, title = '' } = opts;
|
|
16
|
+
const cx = Math.floor(window.innerWidth / 2) - Math.floor(width / 2);
|
|
17
|
+
const cy = Math.floor(window.innerHeight / 2) - 120;
|
|
18
|
+
this._wm.createWindow(this._id, { x: cx, y: cy, width, closable: true, title });
|
|
19
|
+
const body = this._wm.getBody(this._id);
|
|
20
|
+
if (body) body.innerHTML = html;
|
|
21
|
+
this._wm.focus(this._id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
hide() {
|
|
25
|
+
if (this._wm.has(this._id)) this._wm.closeWindow(this._id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
confirm(message, opts = {}) {
|
|
29
|
+
const { okText = 'OK', cancelText = 'Cancel', title = 'Confirm' } = opts;
|
|
30
|
+
const pfx = this._pfx;
|
|
31
|
+
return new Promise(resolve => {
|
|
32
|
+
this.show(`
|
|
33
|
+
<div class="${pfx}-modal-body">${message}</div>
|
|
34
|
+
<div class="${pfx}-modal-footer">
|
|
35
|
+
<button class="${pfx}-btn ${pfx}-modal-ok">${okText}</button>
|
|
36
|
+
<button class="${pfx}-btn ${pfx}-modal-cancel">${cancelText}</button>
|
|
37
|
+
</div>`, { title });
|
|
38
|
+
const body = this._wm.getBody(this._id);
|
|
39
|
+
if (!body) return resolve(false);
|
|
40
|
+
body.querySelector(`.${pfx}-modal-ok`).onclick = () => { this.hide(); resolve(true); };
|
|
41
|
+
body.querySelector(`.${pfx}-modal-cancel`).onclick = () => { this.hide(); resolve(false); };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
prompt(message, opts = {}) {
|
|
46
|
+
const { defaultValue = '', okText = 'OK', cancelText = 'Cancel', title = 'Input' } = opts;
|
|
47
|
+
const pfx = this._pfx;
|
|
48
|
+
return new Promise(resolve => {
|
|
49
|
+
this.show(`
|
|
50
|
+
<div class="${pfx}-modal-body">${message}</div>
|
|
51
|
+
<input class="${pfx}-input ${pfx}-modal-input" type="text" value="${defaultValue}">
|
|
52
|
+
<div class="${pfx}-modal-footer">
|
|
53
|
+
<button class="${pfx}-btn ${pfx}-modal-ok">${okText}</button>
|
|
54
|
+
<button class="${pfx}-btn ${pfx}-modal-cancel">${cancelText}</button>
|
|
55
|
+
</div>`, { title });
|
|
56
|
+
const body = this._wm.getBody(this._id);
|
|
57
|
+
if (!body) return resolve(null);
|
|
58
|
+
const input = body.querySelector(`.${pfx}-modal-input`);
|
|
59
|
+
body.querySelector(`.${pfx}-modal-ok`).onclick = () => { this.hide(); resolve(input.value); };
|
|
60
|
+
body.querySelector(`.${pfx}-modal-cancel`).onclick = () => { this.hide(); resolve(null); };
|
|
61
|
+
input.focus();
|
|
62
|
+
input.onkeydown = (e) => {
|
|
63
|
+
if (e.key === 'Enter') { this.hide(); resolve(input.value); }
|
|
64
|
+
if (e.key === 'Escape') { this.hide(); resolve(null); }
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get isOpen() { return this._wm.has(this._id); }
|
|
70
|
+
}
|