seqeyes-python 0.3.7__tar.gz → 0.3.9__tar.gz
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.
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/PKG-INFO +1 -1
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/pyproject.toml +1 -1
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/src/seqeyes/__init__.py +1 -1
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/src/seqeyes/_renderer.py +28 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/src/seqeyes/resources/pulseq-bundle.js +97 -4
- seqeyes_python-0.3.9/src/seqeyes/resources/seqeyes-labels.js +432 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/src/seqeyes/resources/viewer.html +85 -8
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/src/seqeyes/resources/webview-bundle.js +574 -20
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/tests/test_renderer.py +17 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/.gitignore +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/README.md +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/examples/demo.ipynb +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/examples/demo.py +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/src/seqeyes/_plot.py +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/tests/conftest.py +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/tests/debug_full.html +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/tests/debug_minimal.html +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/tests/debug_viewer.html +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/tests/debug_viewer.py +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/tests/integration_test.py +0 -0
- {seqeyes_python-0.3.7 → seqeyes_python-0.3.9}/tests/test_plot.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: seqeyes-python
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
4
4
|
Summary: Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq.
|
|
5
5
|
Project-URL: Homepage, https://github.com/bughht/seqeyes_plugin
|
|
6
6
|
Project-URL: Repository, https://github.com/bughht/seqeyes_plugin
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "seqeyes-python"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.9"
|
|
8
8
|
description = "Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -26,6 +26,13 @@ _BUNDLE_CANDIDATES = [
|
|
|
26
26
|
Path(__file__).resolve().parent.parent.parent.parent / "web" / "pulseq-bundle.js",
|
|
27
27
|
Path.cwd() / "web" / "pulseq-bundle.js",
|
|
28
28
|
]
|
|
29
|
+
# The label-row module shared with the web renderers. build:web copies it into
|
|
30
|
+
# resources/; a source checkout can read it from the webview assets instead.
|
|
31
|
+
_LABELS_SCRIPT_CANDIDATES = [
|
|
32
|
+
_RESOURCES_DIR / "seqeyes-labels.js",
|
|
33
|
+
Path(__file__).resolve().parent.parent.parent.parent / "src" / "editor" / "webview" / "assets" / "labels.js",
|
|
34
|
+
Path.cwd() / "src" / "editor" / "webview" / "assets" / "labels.js",
|
|
35
|
+
]
|
|
29
36
|
_VALID_GRAD_UNITS = {"Hz/m", "mT/m", "G/cm"}
|
|
30
37
|
SequenceSource = Union[str, bytes, bytearray, memoryview]
|
|
31
38
|
|
|
@@ -42,6 +49,18 @@ def _find_bundle() -> str:
|
|
|
42
49
|
)
|
|
43
50
|
|
|
44
51
|
|
|
52
|
+
def _find_labels_script() -> str:
|
|
53
|
+
"""Locate and read the label-row script (SeqEyesLabels)."""
|
|
54
|
+
for p in _LABELS_SCRIPT_CANDIDATES:
|
|
55
|
+
if p.is_file():
|
|
56
|
+
return p.read_text(encoding="utf-8")
|
|
57
|
+
raise FileNotFoundError(
|
|
58
|
+
"seqeyes-labels.js not found. "
|
|
59
|
+
"Build it with: npm run build:web (from the repo root), "
|
|
60
|
+
"or copy src/editor/webview/assets/labels.js to python/src/seqeyes/resources/seqeyes-labels.js"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
45
64
|
def _read_viewer_template() -> str:
|
|
46
65
|
"""Read the standalone viewer HTML template."""
|
|
47
66
|
if _VIEWER_TEMPLATE_PATH.is_file():
|
|
@@ -109,6 +128,15 @@ def _build_html(
|
|
|
109
128
|
elif bundle_placeholder in template:
|
|
110
129
|
template = template.replace(bundle_placeholder, "")
|
|
111
130
|
|
|
131
|
+
labels_placeholder = "<!-- LABELS_SCRIPT_PLACEHOLDER -->"
|
|
132
|
+
if inject_bundle and labels_placeholder in template:
|
|
133
|
+
template = template.replace(
|
|
134
|
+
labels_placeholder,
|
|
135
|
+
f"<script>\n{_find_labels_script()}\n</script>",
|
|
136
|
+
)
|
|
137
|
+
elif labels_placeholder in template:
|
|
138
|
+
template = template.replace(labels_placeholder, "")
|
|
139
|
+
|
|
112
140
|
# 2. Build options injection block (sequence data + display opts)
|
|
113
141
|
opts = [
|
|
114
142
|
f"window.SEQEYES_RAW_B64 = {json.dumps(source_b64)};",
|
|
@@ -55,6 +55,7 @@ var Pulseq = (() => {
|
|
|
55
55
|
estimateRfCarrierAreaDeg: () => estimateRfCarrierAreaDeg,
|
|
56
56
|
estimateSequenceKspaceCost: () => estimateSequenceKspaceCost,
|
|
57
57
|
estimateSpectrogramCost: () => estimateSpectrogramCost,
|
|
58
|
+
evaluateAdcLabels: () => evaluateAdcLabels,
|
|
58
59
|
exportKspaceArtifacts: () => exportKspaceArtifacts,
|
|
59
60
|
exportKspaceArtifactsFromBytes: () => exportKspaceArtifactsFromBytes,
|
|
60
61
|
exportKspaceArtifactsFromSequence: () => exportKspaceArtifactsFromSequence,
|
|
@@ -65,6 +66,7 @@ var Pulseq = (() => {
|
|
|
65
66
|
hasPulseqBinaryMagic: () => hasPulseqBinaryMagic,
|
|
66
67
|
isEmptyAscProfile: () => isEmptyAscProfile,
|
|
67
68
|
kspaceExceedsInteractiveBudget: () => kspaceExceedsInteractiveBudget,
|
|
69
|
+
listSequenceLabels: () => listSequenceLabels,
|
|
68
70
|
packSequenceBlocks: () => packSequenceBlocks,
|
|
69
71
|
parseAcousticResonancesAsc: () => parseAcousticResonancesAsc,
|
|
70
72
|
parseAscProfile: () => parseAscProfile,
|
|
@@ -87,7 +89,7 @@ var Pulseq = (() => {
|
|
|
87
89
|
});
|
|
88
90
|
|
|
89
91
|
// package.json
|
|
90
|
-
var version = "0.3.
|
|
92
|
+
var version = "0.3.9";
|
|
91
93
|
|
|
92
94
|
// src/pulseq/decompressor.ts
|
|
93
95
|
function decompressShape(compressed, numSamples) {
|
|
@@ -777,7 +779,8 @@ var Pulseq = (() => {
|
|
|
777
779
|
id: toInt(p[0], isSet ? "LABELSET" : "LABELINC", line),
|
|
778
780
|
value: toNumber(p[1], isSet ? "LABELSET" : "LABELINC", line),
|
|
779
781
|
labelId,
|
|
780
|
-
flagId
|
|
782
|
+
flagId,
|
|
783
|
+
name: p[2]
|
|
781
784
|
};
|
|
782
785
|
if (isSet) seq.labelSets.push(spec);
|
|
783
786
|
else seq.labelIncs.push(spec);
|
|
@@ -1226,8 +1229,9 @@ var Pulseq = (() => {
|
|
|
1226
1229
|
if (labelIndex < 1 || labelIndex > BINARY_LABELS.length) {
|
|
1227
1230
|
reader.fail(`invalid binary label index ${labelIndex}`);
|
|
1228
1231
|
}
|
|
1229
|
-
const
|
|
1230
|
-
const
|
|
1232
|
+
const name = BINARY_LABELS[labelIndex - 1];
|
|
1233
|
+
const { labelId, flagId } = decodeLabel(name);
|
|
1234
|
+
const spec = { id, value, labelId, flagId, name };
|
|
1231
1235
|
library.push(spec);
|
|
1232
1236
|
}
|
|
1233
1237
|
}
|
|
@@ -3325,6 +3329,95 @@ var Pulseq = (() => {
|
|
|
3325
3329
|
return d[i0] + (d[i1] - d[i0]) * (t - g[i0]) / dt;
|
|
3326
3330
|
}
|
|
3327
3331
|
|
|
3332
|
+
// src/pulseq/labels.ts
|
|
3333
|
+
var COUNTER_ORDER = ["SLC", "SEG", "REP", "AVG", "SET", "ECO", "PHS", "LIN", "PAR", "ACQ", "TRID", "ONCE"];
|
|
3334
|
+
var FLAG_ORDER = ["NAV", "REV", "SMS", "REF", "IMA", "OFF", "NOISE", "PMC", "NOROT", "NOPOS", "NOSCL"];
|
|
3335
|
+
function labelRank(name) {
|
|
3336
|
+
const counter = COUNTER_ORDER.indexOf(name);
|
|
3337
|
+
if (counter >= 0) return counter;
|
|
3338
|
+
const flag = FLAG_ORDER.indexOf(name);
|
|
3339
|
+
return flag >= 0 ? 2e3 + flag : 1e3;
|
|
3340
|
+
}
|
|
3341
|
+
function labelKind(name) {
|
|
3342
|
+
return FLAG_ORDER.includes(name) ? "flag" : "counter";
|
|
3343
|
+
}
|
|
3344
|
+
function listSequenceLabels(seq) {
|
|
3345
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3346
|
+
for (const spec of seq.labelSets) seen.add(spec.name);
|
|
3347
|
+
for (const spec of seq.labelIncs) seen.add(spec.name);
|
|
3348
|
+
const names = [...seen].sort((a, b) => labelRank(a) - labelRank(b) || (a < b ? -1 : a > b ? 1 : 0));
|
|
3349
|
+
return { names, kinds: names.map(labelKind) };
|
|
3350
|
+
}
|
|
3351
|
+
function labelOpsForChain(seq, headId, column, sets, incs) {
|
|
3352
|
+
const ops = [];
|
|
3353
|
+
const visited = /* @__PURE__ */ new Set();
|
|
3354
|
+
let cur = seq.extensions.get(headId);
|
|
3355
|
+
while (cur && !visited.has(cur.id)) {
|
|
3356
|
+
visited.add(cur.id);
|
|
3357
|
+
const type = seq.extensionTypes.get(cur.type) ?? 999 /* EXT_UNKNOWN */;
|
|
3358
|
+
if (type === 3 /* EXT_LABELSET */ || type === 4 /* EXT_LABELINC */) {
|
|
3359
|
+
const increment = type === 4 /* EXT_LABELINC */;
|
|
3360
|
+
const spec = increment ? incs.get(cur.ref) : sets.get(cur.ref);
|
|
3361
|
+
const index = spec ? column.get(spec.name) : void 0;
|
|
3362
|
+
if (spec && index !== void 0) ops.push({ column: index, value: spec.value, increment });
|
|
3363
|
+
}
|
|
3364
|
+
cur = cur.nextId > 0 ? seq.extensions.get(cur.nextId) : void 0;
|
|
3365
|
+
}
|
|
3366
|
+
return ops;
|
|
3367
|
+
}
|
|
3368
|
+
function evaluateAdcLabels(seq) {
|
|
3369
|
+
const { names, kinds } = listSequenceLabels(seq);
|
|
3370
|
+
const width = names.length;
|
|
3371
|
+
const column = new Map(names.map((name, index) => [name, index]));
|
|
3372
|
+
const sets = new Map(seq.labelSets.map((spec) => [spec.id, spec]));
|
|
3373
|
+
const incs = new Map(seq.labelIncs.map((spec) => [spec.id, spec]));
|
|
3374
|
+
let count = 0;
|
|
3375
|
+
for (const block of seq.blocks) {
|
|
3376
|
+
if (block.adcId > 0 && seq.adcs.has(block.adcId)) count++;
|
|
3377
|
+
}
|
|
3378
|
+
const timeSec = new Float64Array(count);
|
|
3379
|
+
const blockNumbers = new Uint32Array(count);
|
|
3380
|
+
const values = new Int32Array(count * width);
|
|
3381
|
+
const state = new Int32Array(width);
|
|
3382
|
+
const chains = /* @__PURE__ */ new Map();
|
|
3383
|
+
let start = 0;
|
|
3384
|
+
let row = 0;
|
|
3385
|
+
for (const block of seq.blocks) {
|
|
3386
|
+
if (width > 0 && block.extId > 0) {
|
|
3387
|
+
let ops = chains.get(block.extId);
|
|
3388
|
+
if (!ops) {
|
|
3389
|
+
ops = labelOpsForChain(seq, block.extId, column, sets, incs);
|
|
3390
|
+
chains.set(block.extId, ops);
|
|
3391
|
+
}
|
|
3392
|
+
for (const op of ops) state[op.column] = op.increment ? state[op.column] + op.value : op.value;
|
|
3393
|
+
}
|
|
3394
|
+
const adc = block.adcId > 0 ? seq.adcs.get(block.adcId) : void 0;
|
|
3395
|
+
if (adc) {
|
|
3396
|
+
timeSec[row] = start + adc.delay * 1e-6 + adc.numSamples * adc.dwell * 1e-9 / 2;
|
|
3397
|
+
blockNumbers[row] = block.num;
|
|
3398
|
+
values.set(state, row * width);
|
|
3399
|
+
row++;
|
|
3400
|
+
}
|
|
3401
|
+
start += blockDurationSeconds(seq, block);
|
|
3402
|
+
}
|
|
3403
|
+
const min = new Array(width).fill(0);
|
|
3404
|
+
const max = new Array(width).fill(0);
|
|
3405
|
+
for (let label = 0; label < width; label++) {
|
|
3406
|
+
let lo = Infinity;
|
|
3407
|
+
let hi = -Infinity;
|
|
3408
|
+
for (let adc = 0; adc < count; adc++) {
|
|
3409
|
+
const value = values[adc * width + label];
|
|
3410
|
+
if (value < lo) lo = value;
|
|
3411
|
+
if (value > hi) hi = value;
|
|
3412
|
+
}
|
|
3413
|
+
if (count > 0) {
|
|
3414
|
+
min[label] = lo;
|
|
3415
|
+
max[label] = hi;
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
return { names, kinds, count, timeSec, block: blockNumbers, values, min, max };
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3328
3421
|
// src/pulseq/boundedSeries.ts
|
|
3329
3422
|
var BoundedSeriesBuilder = class {
|
|
3330
3423
|
constructor(startSec, endSec, maxPoints) {
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
MDH label row — markers at each ADC centre, and their style controls
|
|
3
|
+
═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
Shared by all three renderers: the VS Code webview, web/index.html (which
|
|
5
|
+
the MATLAB toolbox also loads) and the Python viewer.html. Each declares
|
|
6
|
+
its own canvas state, so nothing here reads renderer globals — callers pass
|
|
7
|
+
the 2-D context, the time→x mapping and the row geometry. The Python
|
|
8
|
+
viewer loads this file on its own, without the rest of the bundle.
|
|
9
|
+
|
|
10
|
+
The DOM and localStorage are only touched inside functions, so the tests
|
|
11
|
+
can run the shipped file in a bare VM context.
|
|
12
|
+
|
|
13
|
+
A "labels" argument is anything with `names` and `kinds`: the popup and the
|
|
14
|
+
styles need nothing more, so ⚙ works before the per-ADC values arrive. */
|
|
15
|
+
var SeqEyesLabels = (function () {
|
|
16
|
+
var STORAGE_KEY = 'seqeyes.labelStyles.v1';
|
|
17
|
+
// Tableau 10: distinct hues that stay readable on light and dark themes.
|
|
18
|
+
var PALETTE = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'];
|
|
19
|
+
var SHAPES = ['circle', 'square', 'triangle', 'triangleDown', 'diamond', 'cross', 'plus'];
|
|
20
|
+
var SHAPE_NAMES = {
|
|
21
|
+
circle: '● Circle', square: '■ Square', triangle: '▲ Triangle', triangleDown: '▼ Triangle down',
|
|
22
|
+
diamond: '◆ Diamond', cross: '✕ Cross', plus: '+ Plus'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
var overrides = null;
|
|
26
|
+
var popover = null, popoverAnchor = null, popoverLabels = null, popoverChange = null, popoverRows = [];
|
|
27
|
+
|
|
28
|
+
/* ── Table ─────────────────────────────────────────────────────────── */
|
|
29
|
+
|
|
30
|
+
/* Same contract as asTypedView in block-transport.js, repeated because the
|
|
31
|
+
Python viewer loads this file without the bundle. */
|
|
32
|
+
function typedView(buffer, Ctor) {
|
|
33
|
+
if (buffer instanceof Ctor) return buffer;
|
|
34
|
+
if (buffer instanceof ArrayBuffer) return new Ctor(buffer);
|
|
35
|
+
if (buffer && buffer.buffer instanceof ArrayBuffer && typeof buffer.byteLength === 'number')
|
|
36
|
+
return new Ctor(buffer.buffer, buffer.byteOffset || 0, Math.floor(buffer.byteLength / Ctor.BYTES_PER_ELEMENT));
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeTable(names, kinds, count, timeSec, block, values, min, max) {
|
|
41
|
+
var width = names.length, table = {
|
|
42
|
+
names: Array.prototype.slice.call(names), kinds: [], count: count,
|
|
43
|
+
timeSec: timeSec, block: block, values: values, min: [], max: []
|
|
44
|
+
};
|
|
45
|
+
for (var i = 0; i < width; i++) {
|
|
46
|
+
table.kinds.push(kinds && kinds[i] === 'flag' ? 'flag' : 'counter');
|
|
47
|
+
table.min.push(min && isFinite(min[i]) ? min[i] : 0);
|
|
48
|
+
table.max.push(max && isFinite(max[i]) ? max[i] : 0);
|
|
49
|
+
}
|
|
50
|
+
return table;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Rebuild the table the extension host serialized (labelTransport.ts). */
|
|
54
|
+
function fromPayload(payload) {
|
|
55
|
+
if (!payload || !payload.names) throw new Error('the label table did not arrive from the extension host');
|
|
56
|
+
var count = payload.count | 0, width = payload.names.length;
|
|
57
|
+
var time = typedView(payload.timeSec, Float64Array), block = typedView(payload.block, Uint32Array),
|
|
58
|
+
values = typedView(payload.values, Int32Array);
|
|
59
|
+
if (!time || !block || !values) throw new Error('the label values did not arrive as binary data');
|
|
60
|
+
if (time.length < count || block.length < count || values.length < count * width)
|
|
61
|
+
throw new Error('the label values arrived truncated');
|
|
62
|
+
return makeTable(payload.names, payload.kinds, count, time.subarray(0, count), block.subarray(0, count),
|
|
63
|
+
values.subarray(0, count * width), payload.min, payload.max);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Wrap a table evaluated in the page by Pulseq.evaluateAdcLabels. */
|
|
67
|
+
function fromTable(table) {
|
|
68
|
+
return makeTable(table.names, table.kinds, table.count, table.timeSec, table.block, table.values, table.min, table.max);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── Styles ────────────────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
function readOverrides() {
|
|
74
|
+
if (overrides) return overrides;
|
|
75
|
+
overrides = {};
|
|
76
|
+
try {
|
|
77
|
+
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
|
78
|
+
if (saved && typeof saved === 'object') overrides = saved;
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
return overrides;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeOverrides() {
|
|
84
|
+
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); } catch (_) {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Defaults follow the label's position in its sequence, so one sequence's
|
|
88
|
+
labels get distinct colours. A user's choice is saved per name and
|
|
89
|
+
follows that label into every sequence. */
|
|
90
|
+
function styleFor(labels, index) {
|
|
91
|
+
var saved = readOverrides()[labels.names[index]] || {};
|
|
92
|
+
return {
|
|
93
|
+
shape: SHAPES.indexOf(saved.shape) >= 0 ? saved.shape : SHAPES[index % SHAPES.length],
|
|
94
|
+
color: /^#[0-9a-f]{6}$/i.test(saved.color || '') ? saved.color : PALETTE[index % PALETTE.length],
|
|
95
|
+
visible: saved.visible !== false
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setStyle(name, patch) {
|
|
100
|
+
var all = readOverrides(), current = all[name] || {};
|
|
101
|
+
for (var key in patch) if (Object.prototype.hasOwnProperty.call(patch, key)) current[key] = patch[key];
|
|
102
|
+
all[name] = current;
|
|
103
|
+
writeOverrides();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resetStyles(names) {
|
|
107
|
+
var all = readOverrides();
|
|
108
|
+
for (var i = 0; i < names.length; i++) delete all[names[i]];
|
|
109
|
+
writeOverrides();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ── Drawing ───────────────────────────────────────────────────────── */
|
|
113
|
+
|
|
114
|
+
function lowerBound(values, target, count) {
|
|
115
|
+
var lo = 0, hi = count;
|
|
116
|
+
while (lo < hi) { var mid = (lo + hi) >> 1; if (values[mid] < target) lo = mid + 1; else hi = mid; }
|
|
117
|
+
return lo;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function upperBound(values, target, count) {
|
|
121
|
+
var lo = 0, hi = count;
|
|
122
|
+
while (lo < hi) { var mid = (lo + hi) >> 1; if (values[mid] <= target) lo = mid + 1; else hi = mid; }
|
|
123
|
+
return lo;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function addMarker(ctx, shape, x, y, r) {
|
|
127
|
+
if (shape === 'circle') { ctx.moveTo(x + r, y); ctx.arc(x, y, r, 0, 6.283185); }
|
|
128
|
+
else if (shape === 'square') { var h = r * .85; ctx.rect(x - h, y - h, 2 * h, 2 * h); }
|
|
129
|
+
else if (shape === 'triangle') { ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * .8); ctx.lineTo(x - r, y + r * .8); ctx.closePath(); }
|
|
130
|
+
else if (shape === 'triangleDown') { ctx.moveTo(x, y + r); ctx.lineTo(x + r, y - r * .8); ctx.lineTo(x - r, y - r * .8); ctx.closePath(); }
|
|
131
|
+
else if (shape === 'diamond') { ctx.moveTo(x, y - r); ctx.lineTo(x + r, y); ctx.lineTo(x, y + r); ctx.lineTo(x - r, y); ctx.closePath(); }
|
|
132
|
+
else if (shape === 'cross') { ctx.moveTo(x - r, y - r); ctx.lineTo(x + r, y + r); ctx.moveTo(x + r, y - r); ctx.lineTo(x - r, y + r); }
|
|
133
|
+
else { ctx.moveTo(x - r, y); ctx.lineTo(x + r, y); ctx.moveTo(x, y - r); ctx.lineTo(x, y + r); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function paint(ctx, style) {
|
|
137
|
+
if (style.shape === 'cross' || style.shape === 'plus') {
|
|
138
|
+
ctx.strokeStyle = style.color; ctx.lineWidth = 1.5; ctx.stroke();
|
|
139
|
+
} else {
|
|
140
|
+
ctx.fillStyle = style.color; ctx.fill();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Each counter spans its own min–max, so SLC 0–3 stays readable beside
|
|
145
|
+
LIN 0–255. Flags are pinned to 0/1; a constant label sits mid-row. */
|
|
146
|
+
function valueScale(labels, index, top, span) {
|
|
147
|
+
var flag = labels.kinds[index] === 'flag';
|
|
148
|
+
var lo = flag ? 0 : labels.min[index], hi = flag ? 1 : labels.max[index];
|
|
149
|
+
return { lo: lo, k: hi > lo ? span / (hi - lo) : 0, bottom: top + span, mid: top + span / 2 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function yFor(scale, value) { return scale.k ? scale.bottom - (value - scale.lo) * scale.k : scale.mid; }
|
|
153
|
+
|
|
154
|
+
/* Keep one pixel column's lowest and highest value per label. */
|
|
155
|
+
function addColumnExtremes(ctx, table, label, first, last, left, plotW, scale, shape, r, t2x) {
|
|
156
|
+
var width = table.names.length, columns = Math.max(1, Math.floor(plotW)), column = -1, lo = 0, hi = 0, drawn = 0;
|
|
157
|
+
for (var adc = first; adc <= last; adc++) {
|
|
158
|
+
var c = -2, v = 0;
|
|
159
|
+
if (adc < last) {
|
|
160
|
+
c = Math.floor(t2x(table.timeSec[adc]) - left);
|
|
161
|
+
c = c < 0 ? 0 : (c >= columns ? columns - 1 : c);
|
|
162
|
+
v = table.values[adc * width + label];
|
|
163
|
+
if (c === column) { if (v < lo) lo = v; if (v > hi) hi = v; continue; }
|
|
164
|
+
}
|
|
165
|
+
if (column >= 0) {
|
|
166
|
+
addMarker(ctx, shape, left + column + .5, yFor(scale, lo), r); drawn++;
|
|
167
|
+
if (hi !== lo) { addMarker(ctx, shape, left + column + .5, yFor(scale, hi), r); drawn++; }
|
|
168
|
+
}
|
|
169
|
+
column = c; lo = v; hi = v;
|
|
170
|
+
}
|
|
171
|
+
return drawn;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* A compact key in the row's top-left corner, so the markers read without
|
|
175
|
+
opening the controls. */
|
|
176
|
+
function drawKey(ctx, labels, geom) {
|
|
177
|
+
var x = geom.left + 5, y = geom.top + 8, limit = geom.right - 8, items = [], widths = [], total = 6;
|
|
178
|
+
ctx.save();
|
|
179
|
+
ctx.font = '9px monospace'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
|
|
180
|
+
for (var i = 0; i < labels.names.length; i++) {
|
|
181
|
+
var style = styleFor(labels, i);
|
|
182
|
+
if (!style.visible) continue;
|
|
183
|
+
var w = 10 + ctx.measureText(labels.names[i]).width + 8;
|
|
184
|
+
if (x + total + w > limit) break;
|
|
185
|
+
items.push({ index: i, style: style }); widths.push(w); total += w;
|
|
186
|
+
}
|
|
187
|
+
if (items.length) {
|
|
188
|
+
ctx.globalAlpha = .78; ctx.fillStyle = geom.background || 'rgba(128,128,128,.15)';
|
|
189
|
+
ctx.fillRect(x, y - 6, total, 12);
|
|
190
|
+
ctx.globalAlpha = 1;
|
|
191
|
+
var cursor = x + 6;
|
|
192
|
+
for (var j = 0; j < items.length; j++) {
|
|
193
|
+
ctx.beginPath(); addMarker(ctx, items[j].style.shape, cursor + 3, y, 3); paint(ctx, items[j].style);
|
|
194
|
+
ctx.fillStyle = geom.foreground || items[j].style.color;
|
|
195
|
+
ctx.fillText(labels.names[items[j].index], cursor + 10, y + .5);
|
|
196
|
+
cursor += widths[j];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
ctx.restore();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* Draw the label row. geom = {left, right, top, height, background,
|
|
203
|
+
foreground} in CSS pixels; t2x maps seconds to x. Every ADC gets a
|
|
204
|
+
marker while they sit at least three pixels apart; beyond that each pixel
|
|
205
|
+
column keeps only its lowest and highest value per label, so the cost is
|
|
206
|
+
bounded by the plot width rather than the ADC count. Returns the number
|
|
207
|
+
of markers drawn. */
|
|
208
|
+
function drawRow(ctx, table, geom, vs, ve, t2x) {
|
|
209
|
+
if (!table || !table.count || !table.names.length) return 0;
|
|
210
|
+
var first = lowerBound(table.timeSec, vs, table.count), last = upperBound(table.timeSec, ve, table.count);
|
|
211
|
+
var width = table.names.length, plotW = Math.max(1, geom.right - geom.left), drawn = 0;
|
|
212
|
+
if (last > first) {
|
|
213
|
+
var pad = Math.max(5, Math.min(16, geom.height * .2)), top = geom.top + pad, span = Math.max(1, geom.height - 2 * pad);
|
|
214
|
+
var r = Math.max(2.5, Math.min(4.5, geom.height * .05)), dense = last - first > plotW / 3;
|
|
215
|
+
ctx.save();
|
|
216
|
+
for (var label = 0; label < width; label++) {
|
|
217
|
+
var style = styleFor(table, label);
|
|
218
|
+
if (!style.visible) continue;
|
|
219
|
+
var scale = valueScale(table, label, top, span);
|
|
220
|
+
ctx.beginPath();
|
|
221
|
+
if (dense) drawn += addColumnExtremes(ctx, table, label, first, last, geom.left, plotW, scale, style.shape, r, t2x);
|
|
222
|
+
else {
|
|
223
|
+
for (var adc = first; adc < last; adc++) {
|
|
224
|
+
addMarker(ctx, style.shape, t2x(table.timeSec[adc]), yFor(scale, table.values[adc * width + label]), r);
|
|
225
|
+
drawn++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
paint(ctx, style);
|
|
229
|
+
}
|
|
230
|
+
ctx.restore();
|
|
231
|
+
}
|
|
232
|
+
if (geom.height >= 34) drawKey(ctx, table, geom);
|
|
233
|
+
return drawn;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* The label state at the ADC of `block` ({i, s, d}, as in the renderers'
|
|
237
|
+
block lists), or null when that block has no ADC. */
|
|
238
|
+
function tooltipLine(table, block) {
|
|
239
|
+
if (!table || !table.count || !block) return null;
|
|
240
|
+
var index = lowerBound(table.timeSec, block.s, table.count);
|
|
241
|
+
if (index >= table.count || table.block[index] !== block.i || table.timeSec[index] > block.s + block.d) return null;
|
|
242
|
+
var width = table.names.length, parts = [];
|
|
243
|
+
for (var label = 0; label < width; label++) {
|
|
244
|
+
if (styleFor(table, label).visible) parts.push(table.names[label] + '=' + table.values[index * width + label]);
|
|
245
|
+
}
|
|
246
|
+
return parts.length ? 'Labels: ' + parts.join(' ') : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ── Controls popup ────────────────────────────────────────────────── */
|
|
250
|
+
|
|
251
|
+
function isCompactLayout() {
|
|
252
|
+
return !!(window.matchMedia && window.matchMedia('(max-width: 768px), (pointer: coarse)').matches);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function element(tag, className, text) {
|
|
256
|
+
var node = document.createElement(tag);
|
|
257
|
+
if (className) node.className = className;
|
|
258
|
+
if (text !== undefined) node.textContent = text;
|
|
259
|
+
return node;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function drawPreview(canvas, style) {
|
|
263
|
+
var dpr = window.devicePixelRatio || 1, size = 16, context = canvas.getContext('2d');
|
|
264
|
+
canvas.width = size * dpr; canvas.height = size * dpr;
|
|
265
|
+
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
266
|
+
context.clearRect(0, 0, size, size);
|
|
267
|
+
context.beginPath(); addMarker(context, style.shape, size / 2, size / 2, 5); paint(context, style);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function controlRow(index) {
|
|
271
|
+
var name = popoverLabels.names[index], row = element('div', 'lblc-row');
|
|
272
|
+
var toggle = element('input'); toggle.type = 'checkbox'; toggle.setAttribute('aria-label', 'Show ' + name);
|
|
273
|
+
var preview = element('canvas'); preview.setAttribute('aria-hidden', 'true');
|
|
274
|
+
var title = element('span', 'lblc-name', name);
|
|
275
|
+
if (popoverLabels.kinds[index] === 'flag') title.appendChild(element('span', 'lblc-kind', 'flag'));
|
|
276
|
+
var shape = element('select'); shape.setAttribute('aria-label', name + ' marker shape');
|
|
277
|
+
for (var s = 0; s < SHAPES.length; s++) {
|
|
278
|
+
var option = element('option', null, SHAPE_NAMES[SHAPES[s]]); option.value = SHAPES[s]; shape.appendChild(option);
|
|
279
|
+
}
|
|
280
|
+
var custom = element('input'); custom.type = 'color'; custom.title = 'Custom colour';
|
|
281
|
+
custom.setAttribute('aria-label', name + ' custom marker colour');
|
|
282
|
+
var swatches = element('div', 'lblc-swatches'), swatchButtons = [];
|
|
283
|
+
PALETTE.forEach(function (color) {
|
|
284
|
+
var swatch = element('button', 'lblc-swatch'); swatch.type = 'button';
|
|
285
|
+
swatch.style.background = color; swatch.title = color;
|
|
286
|
+
swatch.setAttribute('aria-label', 'Use ' + color + ' for ' + name);
|
|
287
|
+
swatch.onclick = function () { update({ color: color }); };
|
|
288
|
+
swatches.appendChild(swatch); swatchButtons.push(swatch);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
function refresh() {
|
|
292
|
+
var style = styleFor(popoverLabels, index);
|
|
293
|
+
row.classList.toggle('off', !style.visible);
|
|
294
|
+
toggle.checked = style.visible; shape.value = style.shape;
|
|
295
|
+
if (custom.value.toLowerCase() !== style.color.toLowerCase()) custom.value = style.color;
|
|
296
|
+
for (var i = 0; i < swatchButtons.length; i++)
|
|
297
|
+
swatchButtons[i].setAttribute('aria-pressed', PALETTE[i].toLowerCase() === style.color.toLowerCase() ? 'true' : 'false');
|
|
298
|
+
drawPreview(preview, style);
|
|
299
|
+
}
|
|
300
|
+
function update(patch) { setStyle(name, patch); refresh(); if (popoverChange) popoverChange(); }
|
|
301
|
+
|
|
302
|
+
toggle.onchange = function () { update({ visible: toggle.checked }); };
|
|
303
|
+
shape.onchange = function () { update({ shape: shape.value }); };
|
|
304
|
+
// Updating in place, never re-rendering, keeps a native picker open while it drags.
|
|
305
|
+
custom.oninput = function () { update({ color: custom.value }); };
|
|
306
|
+
|
|
307
|
+
row.appendChild(toggle); row.appendChild(preview); row.appendChild(title);
|
|
308
|
+
row.appendChild(shape); row.appendChild(custom); row.appendChild(swatches);
|
|
309
|
+
refresh();
|
|
310
|
+
return { row: row, refresh: refresh };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderControls() {
|
|
314
|
+
popover.textContent = ''; popoverRows = [];
|
|
315
|
+
var head = element('div', 'lblc-head');
|
|
316
|
+
head.appendChild(element('span', null, 'Label markers'));
|
|
317
|
+
var close = element('button', 'lblc-close', '✕'); close.type = 'button'; close.setAttribute('aria-label', 'Close');
|
|
318
|
+
close.onclick = function () { closeControls(true); };
|
|
319
|
+
head.appendChild(close);
|
|
320
|
+
var list = element('div', 'lblc-list');
|
|
321
|
+
for (var i = 0; i < popoverLabels.names.length; i++) {
|
|
322
|
+
var entry = controlRow(i); popoverRows.push(entry); list.appendChild(entry.row);
|
|
323
|
+
}
|
|
324
|
+
var foot = element('div', 'lblc-foot');
|
|
325
|
+
function action(text, apply) {
|
|
326
|
+
var button = element('button', null, text); button.type = 'button';
|
|
327
|
+
button.onclick = function () {
|
|
328
|
+
apply();
|
|
329
|
+
for (var r = 0; r < popoverRows.length; r++) popoverRows[r].refresh();
|
|
330
|
+
if (popoverChange) popoverChange();
|
|
331
|
+
};
|
|
332
|
+
foot.appendChild(button);
|
|
333
|
+
}
|
|
334
|
+
action('Show all', function () { popoverLabels.names.forEach(function (name) { setStyle(name, { visible: true }); }); });
|
|
335
|
+
action('Hide all', function () { popoverLabels.names.forEach(function (name) { setStyle(name, { visible: false }); }); });
|
|
336
|
+
action('Reset', function () { resetStyles(popoverLabels.names); });
|
|
337
|
+
popover.appendChild(head); popover.appendChild(list); popover.appendChild(foot);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function positionPopover() {
|
|
341
|
+
if (!popover) return;
|
|
342
|
+
var compact = isCompactLayout();
|
|
343
|
+
popover.classList.toggle('sheet', compact);
|
|
344
|
+
if (compact) { popover.style.left = ''; popover.style.top = ''; return; }
|
|
345
|
+
var rect = popoverAnchor && popoverAnchor.isConnected ? popoverAnchor.getBoundingClientRect() : { left: 8, top: 8, bottom: 8 };
|
|
346
|
+
var width = popover.offsetWidth, height = popover.offsetHeight, vw = window.innerWidth, vh = window.innerHeight;
|
|
347
|
+
var top = rect.bottom + 4;
|
|
348
|
+
if (top + height > vh - 8) top = Math.max(8, rect.top - height - 4);
|
|
349
|
+
if (top + height > vh - 8) top = Math.max(8, vh - height - 8);
|
|
350
|
+
popover.style.left = Math.max(8, Math.min(rect.left, vw - width - 8)) + 'px';
|
|
351
|
+
popover.style.top = top + 'px';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function onKeyDown(event) {
|
|
355
|
+
if (event.key !== 'Escape') return;
|
|
356
|
+
event.preventDefault(); event.stopPropagation();
|
|
357
|
+
closeControls(true);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function onPointerDown(event) {
|
|
361
|
+
var target = event.target;
|
|
362
|
+
if (popover.contains(target)) return;
|
|
363
|
+
// The chip toggles on its own click; closing here first would reopen it.
|
|
364
|
+
if (target && target.closest && target.closest('.lbl-gear')) return;
|
|
365
|
+
closeControls(false);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function openControls(anchor, labels, onChange) {
|
|
369
|
+
closeControls(false);
|
|
370
|
+
if (!labels || !labels.names || !labels.names.length) return;
|
|
371
|
+
popover = element('div', 'lblc'); popover.id = 'labelControls';
|
|
372
|
+
popover.setAttribute('role', 'dialog'); popover.setAttribute('aria-label', 'Label markers');
|
|
373
|
+
popoverAnchor = anchor; popoverLabels = labels; popoverChange = onChange;
|
|
374
|
+
renderControls();
|
|
375
|
+
document.body.appendChild(popover);
|
|
376
|
+
if (anchor) anchor.setAttribute('aria-expanded', 'true');
|
|
377
|
+
positionPopover();
|
|
378
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
379
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
380
|
+
window.addEventListener('resize', positionPopover);
|
|
381
|
+
var first = popover.querySelector('.lblc-row input');
|
|
382
|
+
if (first) first.focus();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function closeControls(restoreFocus) {
|
|
386
|
+
if (!popover) return;
|
|
387
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
388
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
389
|
+
window.removeEventListener('resize', positionPopover);
|
|
390
|
+
if (popover.parentNode) popover.parentNode.removeChild(popover);
|
|
391
|
+
var anchor = popoverAnchor;
|
|
392
|
+
popover = null; popoverAnchor = null; popoverLabels = null; popoverChange = null; popoverRows = [];
|
|
393
|
+
if (anchor) {
|
|
394
|
+
anchor.setAttribute('aria-expanded', 'false');
|
|
395
|
+
if (restoreFocus && anchor.isConnected) anchor.focus();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* The ⚙ chip that follows the Label chip. Legends are rebuilt wholesale,
|
|
400
|
+
so a fresh chip takes over as the anchor of a popup that is still open. */
|
|
401
|
+
function createGearChip(labels, onChange) {
|
|
402
|
+
var chip = element('div', 'li lbl-gear', '⚙');
|
|
403
|
+
chip.title = 'Label marker shapes, colours and visibility';
|
|
404
|
+
chip.tabIndex = 0;
|
|
405
|
+
chip.setAttribute('role', 'button');
|
|
406
|
+
chip.setAttribute('aria-haspopup', 'dialog');
|
|
407
|
+
chip.setAttribute('aria-expanded', popover ? 'true' : 'false');
|
|
408
|
+
if (popover) { popoverAnchor = chip; popoverLabels = labels; popoverChange = onChange; }
|
|
409
|
+
function toggle() { if (popover) closeControls(false); else openControls(chip, labels, onChange); }
|
|
410
|
+
chip.onclick = toggle;
|
|
411
|
+
chip.onkeydown = function (event) {
|
|
412
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggle(); }
|
|
413
|
+
};
|
|
414
|
+
return chip;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
PALETTE: PALETTE,
|
|
419
|
+
SHAPES: SHAPES,
|
|
420
|
+
fromPayload: fromPayload,
|
|
421
|
+
fromTable: fromTable,
|
|
422
|
+
styleFor: styleFor,
|
|
423
|
+
setStyle: setStyle,
|
|
424
|
+
resetStyles: resetStyles,
|
|
425
|
+
drawRow: drawRow,
|
|
426
|
+
tooltipLine: tooltipLine,
|
|
427
|
+
createGearChip: createGearChip,
|
|
428
|
+
openControls: openControls,
|
|
429
|
+
closeControls: closeControls,
|
|
430
|
+
isOpen: function () { return !!popover; }
|
|
431
|
+
};
|
|
432
|
+
})();
|