second commit

This commit is contained in:
2026-04-13 23:14:05 +02:00
parent 1c031ebf63
commit e08597fc72
125 changed files with 26221 additions and 0 deletions
@@ -0,0 +1,34 @@
.container {
height: 100%;
padding: 0.25em;
display: grid;
grid-template-columns: 180px 1fr 150px;
grid-template-rows: 64px 1fr;
gap: 1em 1em;
grid-auto-flow: row;
grid-template-areas:
"header header header"
"move scanner scan"
}
.header {
align-self: stretch;
grid-area: header;
}
.scanner {
justify-self: center;
align-self: center;
grid-area: scanner;
}
.scan {
padding: 0 0.25em;
align-self: start;
grid-area: scan;
}
.move {
align-self: start;
grid-area: move;
}
@@ -0,0 +1,28 @@
.multiwell_cards {
display: grid;
/*grid-auto-columns: 1fr;
grid-auto-rows: 1fr;*/
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 0.1em;
grid-template-areas:
". ."
". ."
". .";
}
button.multiwell {
padding: 0.15em ;
}
#image-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns, 4), 1fr);
gap: 0.5em;
width: 100%;
height: 100%;
padding: 0.5em;
justify-items: center;
align-items: center;
}
@@ -0,0 +1,30 @@
.container {
height: 100%;
padding: 0.25em;
display: grid;
grid-template-columns: 180px 1fr;
grid-template-rows: 64px 0.5fr;
gap: 1em 1em;
grid-auto-flow: row;
grid-template-areas:
"header header"
"scan scanner"
}
.header {
align-self: stretch;
grid-area: header;
}
.scanner {
justify-self: center;
align-self: center;
grid-area: scanner;
}
.scan {
align-self: start;
grid-area: scan;
}
@@ -0,0 +1,21 @@
:root{
--track-height:10px;
--track-color:#ddd;
--range-color:#4aa3ff;
--thumb-size:18px;
--thumb-color:#fff;
--thumb-border:#4aa3ff;
}
.slider-container{ width:100%; max-width:640px; margin:32px auto; }
.labels{ display:flex; justify-content:space-between; margin-bottom:12px; font-size:14px; }
.range-wrap{ position:relative; height:var(--thumb-size); user-select:none; touch-action:none; }
.track{ position:absolute; left:0; right:0; top:50%; transform:translateY(-50%); height:var(--track-height); background:var(--track-color); border-radius:999px; }
.range-highlight{ position:absolute; top:50%; transform:translateY(-50%); height:var(--track-height); background:var(--range-color); border-radius:999px; }
input[type=range]{ -webkit-appearance:none; appearance:none; position:absolute; left:0; right:0; top:0; width:100%; height:100%; background:transparent; pointer-events:none; }
input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none; pointer-events:auto; width:var(--thumb-size); height:var(--thumb-size); border-radius:50%; background:var(--thumb-color); border:2px solid var(--thumb-border); box-shadow:0 1px 3px rgba(0,0,0,0.15); cursor:pointer; margin-top: calc((var(--track-height) - var(--thumb-size)) / 2); }
input[type=range]::-moz-range-thumb{ pointer-events:auto; width:var(--thumb-size); height:var(--thumb-size); border-radius:50%; background:var(--thumb-color); border:2px solid var(--thumb-border); box-shadow:0 1px 3px rgba(0,0,0,0.15); cursor:pointer; }
input[type=range]::-moz-range-track{ background:transparent; border:0; }
@media (max-width:520px){ .values{ flex-direction:column; align-items:flex-start; } .readable{ min-width:unset; width:100%; } }
@@ -0,0 +1,39 @@
.multiwell_cards {
display: grid;
/*grid-auto-columns: 1fr;
grid-auto-rows: 1fr;*/
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 0.1em;
grid-template-areas:
". ."
". ."
". .";
}
button.multiwell {
padding: 0.15em ;
}
#replay-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns, 1), 1fr);
gap: 0.5em;
width: 100%;
height: 100%;
padding: 0.5em;
justify-items: center;
align-items: center;
}
.replay-slider, .replay-sub-slide, .replay_ts_cursor{
width: 100%;
flex: 0 0 auto;
}
.replay-timeline {
width: 100%;
background: transparent;
display: block;
}
@@ -0,0 +1,31 @@
.container {
padding: 0.25em;
display: grid;
grid-template-columns: 0.25fr 1.5fr;
grid-template-rows: 0.5fr 3fr 0.5fr;
grid-auto-columns: 1fr;
gap: 1em 1em;
grid-auto-flow: row;
grid-template-areas:
"header header"
"move scanner"
"command command";
}
.header {
align-self: stretch;
grid-area: header;
}
.command { grid-area: command; }
.scanner {
justify-self: center;
align-self: center;
grid-area: scanner;
}
.move {
align-self: stretch;
grid-area: move;
}
@@ -0,0 +1,138 @@
class ScannerManager {
constructor(container) {
this.container = container;
this.socket = null;
this.axes = 0;
this.cropping = 0;
this.debug_count = 0
}
toggle_median() { this.axes = !this.axes; return this.axes; }
toggle_crop() { this.croping = !this.croping; return this.croping; }
init_controls() {
this.ts = sId("_ts");
const goto_0 = sId("_goto-0");
const goto_xy = sId("_goto-xy");
const xy_base = sId("_xy-base");
const xy_step = sId("_xy-step");
const up = sId("_up");
const down = sId("_down");
const left = sId("_left");
const right = sId("_right");
this.feed = sId("_feed");
this.step = sId("_step");
this.well = sId("_well");
this.x = sId("_x");
this.y = sId("_y");
this.dx = sId("_dx");
this.dy = sId("_dy");
this.xbase = sId("_xbase");
this.ybase = sId("_ybase");
this.debug = sId("_debug");
const test = sId("_test");
const halt = sId("_halt");
const median = sId("_median");
const crop = sId("_crop");
const crop_radius = sId("_crop_radius");
up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
right.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "right" }); });
goto_0.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_0" }); });
goto_xy.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_xy" }); });
xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
xy_step.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_step" }); });
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); });
crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: crop_radius.value }); });
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); });
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
this.dx.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dx", value: e.target.value }); });
this.dy.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dy", value: e.target.value }); });
test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
}
registerSocket(socket) {
this.socket = socket;
this.init_controls();
}
update(payload) {
try {
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; }
if (payload.dxy) { this.dy.value=payload.dy; this.dx.value=payload.dx; }
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
} catch(e) { console.log(e); }
}
init() {
this.axes = 0;
this.cropping = 0;
this._send({
type: 'calibrate',
topic: "init",
feed: this.feed.value,
step: this.step.value,
position: this.well.value
});
}
start() { this._send({ type: 'scanner', topic: "start"}); }
halt() { this._send({ type: 'scanner', topic: "halt" }); }
_send(message) { this.socket.send(message); }
}
class MetadataSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.manager = null;
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.reconnect = false;
}
setManager(manager) { this.manager = manager; }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.manager.update(data);
};
this.ws.onopen = (event) => {
if (this.manager && !this.reconnect)
this.manager['init']();
this.reconnect = false;
};
this.ws.onclose = () => {
console.warn(`WebSocket closed...`);
if (this.shouldReconnect) {
this.reconnect = true;
setTimeout(() => {
console.log("Reconnect WebSocket...");
this.connect();
}, this.reconnectDelay);
}
};
}
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
}
@@ -0,0 +1,92 @@
class ScannerManager {
constructor(container) {
this.container = container;
this.socket = null;
this.axes = 0;
this.cropping = 1;
this.debug_count = 0
}
toggle_median() { this.axes = !this.axes; return this.axes; }
toggle_crop() { this.croping = !this.croping; return this.croping; }
init_controls() {
this.session= sId("_session");
this.ts = sId("_ts");
this.x = sId("_x");
this.y = sId("_y");
this.debug = sId("_debug");
const scan = sId("_scan");
const halt = sId("_halt");
const median = sId("_median");
const crop = sId("_crop");
median.addEventListener('click',(e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); });
scan.addEventListener('click', (e) => { this.scan(); });
halt.addEventListener('click', (e) => { this.halt(); });
}
registerSocket(socket) {
this.socket = socket;
this.init_controls();
}
update(payload) {
try {
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
} catch(e) { console.log(e); }
}
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); }
scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); }
halt() { this._send({ type: 'calibrate', topic: "halt" }); }
_send(message) { this.socket.send(message); }
}
class MetadataSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.manager = null;
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.reconnect = false;
}
setManager(manager) { this.manager = manager; }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.manager.update(data);
};
this.ws.onopen = (event) => {
if (this.manager && !this.reconnect)
this.manager['init']();
this.reconnect = false;
};
this.ws.onclose = () => {
console.warn(`WebSocket closed...`);
if (this.shouldReconnect) {
this.reconnect = true;
setTimeout(() => {
console.log("Reconnect WebSocket...");
this.connect();
}, this.reconnectDelay);
}
};
}
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
}
@@ -0,0 +1,220 @@
class DualRangeSlider {
constructor(root, options = {}) {
// root: element containing the slider UI
this.root = (typeof root === 'string') ? document.querySelector(root) : root;
if (!this.root) throw new Error('Root element not found');
// elements
this.slider = this.root.querySelector('.slider');
this.minRange = this.root.querySelector('.min-range');
this.maxRange = this.root.querySelector('.max-range');
this.minNumber = this.root.querySelector('.min-number');
this.maxNumber = this.root.querySelector('.max-number');
this.displayRange = this.root.querySelector('.display-range');
this.highlight = this.root.querySelector('.range-highlight');
// config
this.min = Number(this.slider.dataset.min ?? this.minRange.min ?? 0);
this.max = Number(this.slider.dataset.max ?? this.minRange.max ?? 100);
this.gap = Number(options.gap ?? 0);
this._bindEvents();
this.updateFromInputs();
}
// calc & repaint
updateHighlight() {
const a = Number(this.minRange.value);
const b = Number(this.maxRange.value);
const pctA = (a - this.min) / (this.max - this.min) * 100;
const pctB = (b - this.min) / (this.max - this.min) * 100;
this.highlight.style.left = pctA + '%';
this.highlight.style.width = (pctB - pctA) + '%';
}
// keep values coherent and sync UI
updateFromInputs(triggerSource) {
let a = Number(this.minRange.value);
let b = Number(this.maxRange.value);
if (a > b - this.gap) {
if (triggerSource === 'min') a = b - this.gap;
else if (triggerSource === 'max') b = a + this.gap;
else a = Math.min(a, b - this.gap);
}
a = Math.max(this.min, Math.min(a, this.max));
b = Math.max(this.min, Math.min(b, this.max));
this.minRange.value = a;
this.maxRange.value = b;
this.minNumber.value = a;
this.maxNumber.value = b;
//if (this.displayRange) this.displayRange.textContent = a + ' — ' + b;
if (this.displayRange)
this.displayRange.innerHTML = `<div class="w3-row"><div class="w3-half">${a}</div><div class="w3-half w3-right-align">${b}</div></div>`;
this.updateHighlight();
}
updateFromNumbers() {
let a = Number(this.minNumber.value) || this.min;
let b = Number(this.maxNumber.value) || this.max;
if (a > b - this.gap) a = b - this.gap;
a = Math.max(this.min, Math.min(a, this.max));
b = Math.max(this.min, Math.min(b, this.max));
this.minRange.value = a;
this.maxRange.value = b;
this.minNumber.value = a;
this.maxNumber.value = b;
//if (this.displayRange) this.displayRange.textContent = a + ' — ' + b;
if (this.displayRange)
this.displayRange.innerHTML = `<div class="w3-row"><div class="w3-half">${a}</div><div class="w3-half w3-right-align">${b}</div></div>`;
this.updateHighlight();
}
_bindEvents() {
this.minRange.addEventListener('input', () => this.updateFromInputs('min'));
this.maxRange.addEventListener('input', () => this.updateFromInputs('max'));
this.minNumber.addEventListener('change', () => this.updateFromNumbers());
this.maxNumber.addEventListener('change', () => this.updateFromNumbers());
// click on track reposition nearest thumb
this.slider.addEventListener('click', (e) => {
if (e.target.tagName.toLowerCase() === 'input') return;
const rect = this.slider.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const pct = clickX / rect.width;
const value = Math.round(pct * (this.max - this.min) + this.min);
const distMin = Math.abs(value - Number(this.minRange.value));
const distMax = Math.abs(value - Number(this.maxRange.value));
if (distMin <= distMax) {
this.minRange.value = Math.min(value, Number(this.maxRange.value));
} else {
this.maxRange.value = Math.max(value, Number(this.minRange.value));
}
this.updateFromInputs();
});
}
// public API: get current values
getValues() {
return { min: Number(this.minRange.value), max: Number(this.maxRange.value) };
}
// public API: set values programmatically
setValues(a, b) {
this.minRange.value = a;
this.maxRange.value = b;
this.updateFromInputs();
}
}
/*
// Exemple d'usage
document.addEventListener('DOMContentLoaded', () => {
// initialisation simple
const slider1 = new DualRangeSlider('#myRangeSlider', { gap: 0 });
// accéder aux valeurs
// console.log(slider1.getValues());
// modifier les valeurs depuis le code
// slider1.setValues(10, 50);
});
*/
/**
* Crée le DOM du slider et instancie DualRangeSlider à partir d'options.
* Retourne { instance, root } ou null si erreur.
*
* options = {
* container: selector|Element, // obligatoire
* min: number, max: number, // obligatoire (timestamps)
* valueMin: number, valueMax: number, // optionnel
* ms: boolean, // unité interne en ms
* gap: number, // minimal gap (same unit as min/max)
* classes: { root, slider, ... } // opcional CSS classes overrides
* labels: { left, right } // opcional text
* }
*/
function createAndInitDualRange(options = {}) {
if (!options.container) throw new Error('options.container required');
const parent = (typeof options.container === 'string') ? document.querySelector(options.container) : options.container;
if (!parent) throw new Error('Container element not found');
if (typeof options.min === 'undefined' || typeof options.max === 'undefined') {
throw new Error('options.min and options.max required');
}
const cls = Object.assign({
root: 'slider-container',
display: 'display-range',
rangeWrap: 'range-wrap slider',
sliderSel: 'slider',
track: 'track',
highlight: 'range-highlight',
minRange: 'min-range',
maxRange: 'max-range',
values: 'values',
minNumber: 'min-number',
maxNumber: 'max-number',
}, options.classes || {});
// build root
const root = document.createElement('div');
root.className = cls.root;
// labels
const labels = document.createElement('div');
labels.className = cls.display;
root.appendChild(labels);
// range wrap / slider
const rangeWrap = document.createElement('div');
rangeWrap.className = cls.rangeWrap;
// set data-min/data-max as provided
rangeWrap.dataset.min = String(options.min);
rangeWrap.dataset.max = String(options.max);
const track = document.createElement('div'); track.className = cls.track;
const highlight = document.createElement('div'); highlight.className = cls.highlight;
rangeWrap.appendChild(track);
rangeWrap.appendChild(highlight);
// inputs range
const minR = document.createElement('input'); minR.type = 'range'; minR.className = cls.minRange;
const maxR = document.createElement('input'); maxR.type = 'range'; maxR.className = cls.maxRange;
minR.min = String(options.min); minR.max = String(options.max);
maxR.min = String(options.min); maxR.max = String(options.max);
minR.step = options.step ?? '1'; maxR.step = options.step ?? '1';
minR.value = String(options.valueMin ?? options.min);
maxR.value = String(options.valueMax ?? options.max);
rangeWrap.appendChild(minR); rangeWrap.appendChild(maxR);
root.appendChild(rangeWrap);
// values area
const values = document.createElement('div'); values.className = "w3-row";
const minLabel = document.createElement('div'); minLabel.className = "w3-half";
const minStrong = document.createElement('span'); minStrong.textContent = 'Min)';
const minNum = document.createElement('input'); minNum.type = 'number'; minNum.className = cls.minNumber;
minNum.min = String(options.min); minNum.max = String(options.max); minNum.value = String(options.valueMin ?? options.min); minNum.step = options.step ?? '1';
minLabel.appendChild(minStrong); minLabel.appendChild(minNum);
const maxLabel = document.createElement('div'); maxLabel.className = "w3-half w3-right-align";
const maxStrong = document.createElement('span'); maxStrong.textContent = 'Max';
const maxNum = document.createElement('input'); maxNum.type = 'number'; maxNum.className = cls.maxNumber;
maxNum.min = String(options.min); maxNum.max = String(options.max); maxNum.value = String(options.valueMax ?? options.max);maxNum.step = options.step ?? '1';
maxLabel.appendChild(maxStrong); maxLabel.appendChild(maxNum);
values.appendChild(minLabel); values.appendChild(maxLabel);
root.appendChild(values);
// append to parent
parent.appendChild(root);
const instOptions = { gap: options.gap ?? 0, ms: Boolean(options.ms), callback: options.callback ?? null };
const instance = new DualRangeSlider(root, instOptions);
return instance;
}
@@ -0,0 +1,265 @@
class DualRangeSlider {
constructor(root, options = {}) {
this.root = (typeof root === 'string') ? document.querySelector(root) : root;
if (!this.root) throw new Error('Root element not found');
this.slider = this.root.querySelector('.slider');
this.minRange = this.root.querySelector('.min-range');
this.maxRange = this.root.querySelector('.max-range');
this.minNumber = this.root.querySelector('.min-number');
this.maxNumber = this.root.querySelector('.max-number');
this.displayRange = this.root.querySelector('.display-range');
this.highlight = this.root.querySelector('.range-highlight');
this.useMilliseconds = Boolean(options.ms);
this.callback = options.callback || null;
// lire min/max depuis data- ou attributs, puis normaliser en unité interne
const rawMin = Number(this.slider.dataset.min ?? this.minRange.min ?? 0);
const rawMax = Number(this.slider.dataset.max ?? this.minRange.max ?? 100);
// unité interne = ms si useMilliseconds true, sinon seconds
this.min = this.useMilliseconds ? rawMin : rawMin;
this.max = this.useMilliseconds ? rawMax : rawMax;
// Si ms=true mais HTML inputs fournis en secondes, détecter et convertir automatiquement
// Détection simple : si ms=true et raw values semblent petits (<1e12), on multiplie par 1000
if (this.useMilliseconds) {
const needsConversion = (n) => n && n < 1e12; // timestamp en s
if (needsConversion(this.min)) this.min *= 1000;
if (needsConversion(this.max)) this.max *= 1000;
} else {
// si ms=false mais raw semblent en ms (>1e12), convertir en s
const needsConversion = (n) => n && n > 1e12;
if (needsConversion(this.min)) this.min = Math.floor(this.min / 1000);
if (needsConversion(this.max)) this.max = Math.floor(this.max / 1000);
}
// normaliser les inputs HTML pour qu'ils correspondent à l'unité interne
const setInputRangeAttrs = (el, val) => {
if (!el) return;
if (this.useMilliseconds) el.min = String(this.min);
else el.min = String(this.min);
if (this.useMilliseconds) el.max = String(this.max);
else el.max = String(this.max);
// si value absent, initialiser
if (!el.value) el.value = el.min;
};
setInputRangeAttrs(this.minRange);
setInputRangeAttrs(this.maxRange);
if (this.minNumber) { this.minNumber.min = String(this.min); this.minNumber.max = String(this.max); }
if (this.maxNumber) { this.maxNumber.min = String(this.min); this.maxNumber.max = String(this.max); }
this.gap = Number(options.gap ?? 0) * (this.useMilliseconds ? 1000 : 1);
// if inputs have values, ensure they are in internal unit; convert if necessary
const normalizeInputValue = (el) => {
if (!el) return;
let v = Number(el.value || el.getAttribute('value') || this.min);
if (this.useMilliseconds && v < 1e12) v = v * 1000;
if (!this.useMilliseconds && v > 1e12) v = Math.floor(v / 1000);
el.value = String(Math.max(this.min, Math.min(v, this.max)));
};
normalizeInputValue(this.minRange);
normalizeInputValue(this.maxRange);
normalizeInputValue(this.minNumber);
normalizeInputValue(this.maxNumber);
this._bindEvents();
this.updateFromInputs();
}
_toDate(ts) {
// ts is in internal unit: ms if useMilliseconds else seconds
return this.useMilliseconds ? new Date(Number(ts)) : new Date(Number(ts) * 1000);
}
_formatDate(ts) {
const d = this._toDate(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
updateHighlight() {
const a = Number(this.minRange.value);
const b = Number(this.maxRange.value);
if (this.callback) this.callback(a, b);
const pctA = (a - this.min) / (this.max - this.min) * 100;
const pctB = (b - this.min) / (this.max - this.min) * 100;
this.highlight.style.left = pctA + '%';
this.highlight.style.width = (pctB - pctA) + '%';
}
updateFromInputs(triggerSource) {
let a = Number(this.minRange.value);
let b = Number(this.maxRange.value);
if (a > b - this.gap) {
if (triggerSource === 'min') a = b - this.gap;
else if (triggerSource === 'max') b = a + this.gap;
else a = Math.min(a, b - this.gap);
}
a = Math.max(this.min, Math.min(a, this.max));
b = Math.max(this.min, Math.min(b, this.max));
this.minRange.value = String(a);
this.maxRange.value = String(b);
if (this.minNumber) this.minNumber.value = String(a);
if (this.maxNumber) this.maxNumber.value = String(b);
//if (this.displayRange) this.displayRange.textContent = `${this._formatDate(a)} — ${this._formatDate(b)}`;
if (this.displayRange)
this.displayRange.innerHTML = `<div class="w3-row"><div class="w3-half">${this._formatDate(a)}</div><div class="w3-half w3-right-align">${this._formatDate(b)}</div></div>`;
this.updateHighlight();
}
updateFromNumbers() {
let a = Number(this.minNumber.value) || this.min;
let b = Number(this.maxNumber.value) || this.max;
if (a > b - this.gap) a = b - this.gap;
a = Math.max(this.min, Math.min(a, this.max));
b = Math.max(this.min, Math.min(b, this.max));
this.minRange.value = String(a);
this.maxRange.value = String(b);
this.minNumber.value = String(a);
this.maxNumber.value = String(b);
//if (this.displayRange) this.displayRange.textContent = `${this._formatDate(a)} — ${this._formatDate(b)}`;
if (this.displayRange)
this.displayRange.innerHTML = `<div class="w3-row"><div class="w3-half">${this._formatDate(a)}</div><div class="w3-half w3-right-align">${this._formatDate(b)}</div></div>`;
this.updateHighlight();
}
_bindEvents() {
this.minRange.addEventListener('input', () => this.updateFromInputs('min'));
this.maxRange.addEventListener('input', () => this.updateFromInputs('max'));
if (this.minNumber) this.minNumber.addEventListener('change', () => this.updateFromNumbers());
if (this.maxNumber) this.maxNumber.addEventListener('change', () => this.updateFromNumbers());
this.slider.addEventListener('click', (e) => {
if (e.target.tagName.toLowerCase() === 'input') return;
const rect = this.slider.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const pct = clickX / rect.width;
const value = Math.round(pct * (this.max - this.min) + this.min);
const distMin = Math.abs(value - Number(this.minRange.value));
const distMax = Math.abs(value - Number(this.maxRange.value));
if (distMin <= distMax) {
this.minRange.value = String(Math.min(value, Number(this.maxRange.value)));
} else {
this.maxRange.value = String(Math.max(value, Number(this.minRange.value)));
}
this.updateFromInputs();
});
}
getValues() {
return { min: Number(this.minRange.value), max: Number(this.maxRange.value) };
}
setValues(a, b) {
// a,b given in same unit as useMilliseconds setting
this.minRange.value = String(a);
this.maxRange.value = String(b);
this.updateFromInputs();
}
}
/**
* Crée le DOM du slider et instancie DualRangeSlider à partir d'options.
* Retourne { instance, root } ou null si erreur.
*
* options = {
* container: selector|Element, // obligatoire
* min: number, max: number, // obligatoire (timestamps)
* valueMin: number, valueMax: number, // optionnel
* ms: boolean, // unité interne en ms
* gap: number, // minimal gap (same unit as min/max)
* classes: { root, slider, ... } // opcional CSS classes overrides
* labels: { left, right } // opcional text
* }
*/
function createAndInitDualRange(options = {}) {
if (!options.container) throw new Error('options.container required');
const parent = (typeof options.container === 'string') ? document.querySelector(options.container) : options.container;
if (!parent) throw new Error('Container element not found');
if (typeof options.min === 'undefined' || typeof options.max === 'undefined') {
throw new Error('options.min and options.max required');
}
const cls = Object.assign({
root: 'slider-container',
display: 'display-range',
rangeWrap: 'range-wrap slider',
sliderSel: 'slider',
track: 'track',
highlight: 'range-highlight',
minRange: 'min-range',
maxRange: 'max-range',
values: 'values',
minNumber: 'min-number',
maxNumber: 'max-number',
}, options.classes || {});
// build root
const root = document.createElement('div');
root.className = cls.root;
// labels
const labels = document.createElement('div');
labels.className = cls.display;
root.appendChild(labels);
// range wrap / slider
const rangeWrap = document.createElement('div');
rangeWrap.className = cls.rangeWrap;
// set data-min/data-max as provided
rangeWrap.dataset.min = String(options.min);
rangeWrap.dataset.max = String(options.max);
const track = document.createElement('div'); track.className = cls.track;
const highlight = document.createElement('div'); highlight.className = cls.highlight;
rangeWrap.appendChild(track);
rangeWrap.appendChild(highlight);
// inputs range
const minR = document.createElement('input'); minR.type = 'range'; minR.className = cls.minRange;
const maxR = document.createElement('input'); maxR.type = 'range'; maxR.className = cls.maxRange;
minR.min = String(options.min); minR.max = String(options.max);
maxR.min = String(options.min); maxR.max = String(options.max);
minR.step = options.step ?? '1'; maxR.step = options.step ?? '1';
minR.value = String(options.valueMin ?? options.min);
maxR.value = String(options.valueMax ?? options.max);
rangeWrap.appendChild(minR); rangeWrap.appendChild(maxR);
root.appendChild(rangeWrap);
// values area
const values = document.createElement('div'); values.className = "w3-row";
const minLabel = document.createElement('div'); minLabel.className = "w3-half";
const minStrong = document.createElement('span'); minStrong.textContent = 'Min (ms)';
const minNum = document.createElement('input'); minNum.type = 'number'; minNum.className = cls.minNumber;
minNum.min = String(options.min); minNum.max = String(options.max); minNum.value = String(options.valueMin ?? options.min); minNum.step = options.step ?? '1';
minLabel.appendChild(minStrong); minLabel.appendChild(minNum);
const maxLabel = document.createElement('div'); maxLabel.className = "w3-half w3-right-align";
const maxStrong = document.createElement('span'); maxStrong.textContent = 'Max (ms)';
const maxNum = document.createElement('input'); maxNum.type = 'number'; maxNum.className = cls.maxNumber;
maxNum.min = String(options.min); maxNum.max = String(options.max); maxNum.value = String(options.valueMax ?? options.max);maxNum.step = options.step ?? '1';
maxLabel.appendChild(maxStrong); maxLabel.appendChild(maxNum);
values.appendChild(minLabel); values.appendChild(maxLabel);
root.appendChild(values);
// append to parent
parent.appendChild(root);
const instOptions = { gap: options.gap ?? 0, ms: Boolean(options.ms), callback: options.callback ?? null };
const instance = new DualRangeSlider(root, instOptions);
return instance;
}
@@ -0,0 +1,260 @@
class ReplayProgressBar {
constructor(parent, input) {
this.input = input;
this.parent = parent;
this.isUserSeeking = false;
input.addEventListener("input", () => {
this.isUserSeeking = true;
const percent = input.value / 1000;
this.parent.seek(percent);
});
input.addEventListener("change", () => {
this.isUserSeeking = false;
});
}
update(progress) {
if (this.isUserSeeking) return;
this.input.value = progress * 1000;
}
}
class ReplaySpeedControl {
constructor(parent, initialSpeed = 1) {
this.parent = parent;
this.SPEED_STEPS = [0.25, 0.5, 0.75, 1, 2, 4, 8, 16, 32];
const initialIndex = this.SPEED_STEPS.indexOf(initialSpeed) !== -1
? this.SPEED_STEPS.indexOf(initialSpeed)
: this.SPEED_STEPS.indexOf(1);
this.parent.speed_control.addEventListener("input", () => {
this.parent.speed_label.textContent = this.SPEED_STEPS[this.parent.speed_control.value];
});
this.parent.speed_control.addEventListener("change", () => {
const speed = this.getSpeed();
this.parent.setSpeed(speed);
});
}
getSpeed() {
return this.SPEED_STEPS[this.parent.speed_control.value];
}
updateSpeed(speed) {
this.parent.speed_control.value = this.SPEED_STEPS.indexOf(speed);
this.parent.speed_label.textContent = this.SPEED_STEPS[this.parent.speed_control.value];
}
}
class ReplayManager {
constructor(options = {}) {
this.img = options.img;
this.btplay = options.play;
this.btpause= options.pause;
this.btstop = options.stop;
this.ts = options.ts;
this.btsnapshot = options.snapshot;
this.btvideosnap = options.videosnap;
this.speed_label = options.speed_label;
this.speed_control = options.speed_control;
this.ts_iso = options.ts_iso;
this.percent = options.percent;
this.dt_left = options.dt_left;
this.dt_right = options.dt_right;
this.timeline = options.timeline;
this.uuid = options.uuid;
this.fps = options.fps;
this.video_endpoint = options.video_endpoint;
this.dt_start = options.dt_start;
this.dt_stop = options.dt_stop;
this.video_type = options.video_type || 'mp4';
this.state = null;
this.socket = null;
this.cursor = null;
}
registerSocket(socket) {
this.socket = socket;
}
async start() {
if (!this.uuid) return;
this.btplay.addEventListener('click', (e) => { this.play(); });
this.btpause.addEventListener('click', (e) => { this.pause(); });
this.btstop.addEventListener('click', (e) => { this.stop(); });
this.btsnapshot.addEventListener('click', (e) => { this.snapshot(); });
this.btvideosnap.addEventListener('click', (e) => { this.videosnap(); });
this.speedControl = new ReplaySpeedControl(this);
this.progressBar = new ReplayProgressBar(this, this.timeline);
this._update_container(this.dt_start);
this.updateState("stopped");
}
updateProgessBar(progress) {
if (this.progressBar) {
this.progressBar.update(progress);
}
}
updateState(state) {
const rules = {
stopped: { play: true, pause: false, stop: false, range: true, snapshot: true, videosnap: true },
playing: { play: false, pause: true, stop: true, range: false , snapshot: false, videosnap: false },
paused: { play: true, pause: false, stop: true, range: true , snapshot: true, videosnap: true }
};
const rule = rules[state];
this.btplay.disabled = !rule.play;
this.btpause.disabled = !rule.pause;
this.btstop.disabled = !rule.stop;
this.btsnapshot.disabled = !rule.snapshot;
this.btvideosnap.disabled = !rule.videosnap;
this.timeline.disabled = rule.range;
}
_setState(state) {
this.state = state;
this.updateState(state);
}
_update_container(cursor) {
this.cursor = cursor;
this.filter = {
uuid: this.uuid,
dt_start: cursor,
dt_stop: this.dt_stop,
fps: this.fps,
speed: this.speedControl.getSpeed()
}
this.percent.textContent = '';
this.updateProgessBar(0.0);
const dt = cursor / 1_000;
this.ts_iso.textContent = toLocalISOString(new Date(dt));
this.dt_left.textContent = timestampToLocalISOString(dt/1_000);
this.dt_right.textContent = timestampToLocalISOString(this.dt_stop/1_000_000);
this._setState("stopped");
}
_update_content_slider(ts, progress) {
this.cursor = ts;
this.ts_iso.textContent = toLocalISOString(new Date(ts/1000));
const percent = Math.ceil(progress * 1000000) / 10000;
this.percent.textContent = percent.toFixed(3) +' %';
this.updateProgessBar(progress);
}
update(payload) {
try {
if (payload.jpeg) { this.img.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.ts) { this._update_content_slider(payload.ts, payload.progress); }
if (payload.motif === "video-reset") { this._update_container(payload.dt_start); }
} catch(e) { console.log(e); }
}
init() { this._send({ type: 'replay', action: "init", }); }
play() { if (this.state === "playing") return; this._setState("playing"); this._send({ type: 'replay', action: "play", }); }
pause() { if (this.state !== "playing") return; this._setState("paused"); this._send({ type: 'replay', action: "pause", }); }
stop() { this._setState("stopped"); this._send({ type: 'replay', action: "stop", }); }
setSpeed(speed) { this._send({ type: 'replay', action: "speed", value: speed }); }
seek(percent) { this._send({ type: 'replay', action: "seek", value: percent }); }
videosnap() {
let filename = `${this.uuid}-${this.ts_iso.textContent}.${this.video_type}`;
filename = filename.replace(/ /g, '_');
const ok = confirm(`Télécharger le fichier ?\n\n${filename}`);
if (!ok) return false;
fetch(this.video_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'download',
uuid: this.uuid,
dt_start: this.cursor,
dt_stop: this.dt_stop,
fps: this.fps
})
}).then(res => {
if (!res.ok) throw new Error(res.status);
return res.blob();
}).then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}).catch(error => {
console.error('Erreur:', error);
});
}
snapshot() {
let filename = `${this.uuid}-${this.ts_iso.textContent}.jpg`;
filename = filename.replace(/ /g, '_');
const ok = confirm(`Télécharger le fichier ?\n\n${filename}`);
if (!ok) return false;
const a = document.createElement('a');
a.href = this.img.src;
a.download = filename || 'image';
document.body.appendChild(a);
a.click();
a.remove();
}
_send(message) {
if (!this.uuid) return;
this.socket.send({ ...message, ...this.filter });
}
}
class MetadataSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.manager = null;
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.reconnect = false;
}
setManager(manager) { this.manager = manager; }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.manager.update(data);
};
this.ws.onopen = (event) => {
if (this.manager && !this.reconnect)
this.manager['init']();
this.reconnect = false;
};
this.ws.onclose = () => {
console.warn(`WebSocket closed...`);
if (this.shouldReconnect) {
this.reconnect = true;
setTimeout(() => {
console.log("Reconnect WebSocket...");
this.connect();
}, this.reconnectDelay);
}
};
}
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
}
@@ -0,0 +1,132 @@
class ScannerManager {
constructor(container, multiwells=null) {
this.container = container;
this.socket = null;
this.multiweels = multiwells;
this.axes = 0;
this.cropping = 0;
}
toggle_median() { this.axes = !this.axes; return this.axes; }
toggle_crop() { this.croping = !this.croping; return this.croping; }
init_controls() {
const goto_0 = sId("_goto-0");
const goto_xy = sId("_goto-xy");
const xy_base = sId("_xy-base");
const xy_step = sId("_xy-step");
const up = sId("_up");
const down = sId("_down");
const left = sId("_left");
const right = sId("_right");
this.feed = sId("_feed");
this.step = sId("_step");
this.well = sId("_well");
this.x = sId("_x");
this.y = sId("_y");
this.dx = sId("_dx");
this.dy = sId("_dy");
this.xbase = sId("_xbase");
this.ybase = sId("_ybase");
const test = sId("_test");
const halt = sId("_halt");
const median = sId("_median");
const crop = sId("_crop");
up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
right.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "right" }); });
goto_0.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_0" }); });
goto_xy.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_xy" }); });
xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
xy_step.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_step" }); });
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); });
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "well", value: e.target.value }); });
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
this.dx.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dx", value: e.target.value }); });
this.dy.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dy", value: e.target.value }); });
test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
}
registerSocket(socket) {
this.socket = socket;
this.init_controls();
}
update(payload) {
try {
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; }
if (payload.dxy) { this.dy.value=payload.dy; this.dx.value=payload.dx; }
if (payload.xy) { this.x.textContent=payload.x; this.y.textContent=payload.y; }
//if (payload.ts) { console.log(payload.ts); }
} catch(e) { console.log(e); }
}
init() {
this._send({
type: 'scanner',
topic: "init",
feed: this.feed.value,
step: this.step.value,
well: this.well.value
});
}
start() { this._send({ type: 'scanner', topic: "start"}); }
halt() { this._send({ type: 'scanner', topic: "halt" }); }
_send(message) { this.socket.send(message); }
}
class MetadataSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.manager = null;
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.reconnect = false;
}
setManager(manager) { this.manager = manager; }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.manager.update(data);
};
this.ws.onopen = (event) => {
if (this.manager && !this.reconnect)
this.manager['init']();
this.reconnect = false;
};
this.ws.onclose = () => {
console.warn(`WebSocket closed...`);
if (this.shouldReconnect) {
this.reconnect = true;
setTimeout(() => {
console.log("Reconnect WebSocket...");
this.connect();
}, this.reconnectDelay);
}
};
}
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
}
@@ -0,0 +1,31 @@
const cpu_used = sId('cpu-used');
const shm_used = sId('shm-used');
const mem_used = sId('mem-used');
const disk_used = sId('disk-used');
const ramdisk_used = sId('ramdisk-used');
let autoTimer = null;
async function fetchStats() {
try {
const r = await fetch(stats_endpoint, { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const j = await r.json();
//console.log(j);
const cpu_percent = j.cpu_info.cpu_percent+'%'; cpu_used.style.setProperty("--cpu-used", cpu_percent); cpu_used.title=`Cpu: ${cpu_percent}`;
const shm_length = j.shm.length; shm_used.style.setProperty("--shm_used", shm_length); shm_used.title= `Shm: ${shm_length}`;
const virtual_memory = j.memory_info.virtual_memory.percent+'%'; mem_used.style.setProperty("--mem-used", virtual_memory); mem_used.title=`Mem: ${virtual_memory}`;
const root_percent = j.disk_info.root.percent+'%'; disk_used.style.setProperty("--disk-used", root_percent); disk_used.title=`Disk: ${root_percent}`;
let ramdisk_percent = "0%";
if (! j.ramdisk_info) ramdisk_percent = j.ramdisk_info.percent+'%';
ramdisk_used.style.setProperty("--ramdisk-used", ramdisk_percent); ramdisk_used.title=`Ramdisk: ${ramdisk_percent}`;
} catch (e) {
console.log('Error: ' + e.message);
}
}
function auto_fetch_start() { fetchStats(); autoTimer = setInterval(fetchStats, 5000);}
function auto_fetch_stop() { clearInterval(autoTimer); autoTimer = null; }
auto_fetch_start();