From 30733a1dad57c9522a13f533c540f3391d143d71 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 21:47:54 +0000 Subject: [PATCH 1/7] fix: metadata IDs, save feedback, acquisition info alert, mag labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Acq_ID supports leading zeros (text input + smart increment) (#903) - Sample_ID as string + save confirmation snackbar (#889-893) - Show project/sample/operator/gear in acquisition page (#894) - Add flowcell size (µm) to magnification button labels (#901) --- flows.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flows.json b/flows.json index 4c696ba..692de4c 100644 --- a/flows.json +++ b/flows.json @@ -3174,7 +3174,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3686,7 +3686,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, From 106d8a9372ae3b777da61366e6629226d58b2b97 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 21:48:07 +0000 Subject: [PATCH 2/7] fix: unique EcoTaxa naming and path sanitization - Compose fully-qualified project_sample_acq IDs in update_config (#882) - Space-to-underscore sanitization in IDs - Gallery + Explorer URL path sanitization for legacy TSVs --- flows.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flows.json b/flows.json index 692de4c..88c990d 100644 --- a/flows.json +++ b/flows.json @@ -3844,7 +3844,7 @@ "type": "function", "z": "71ede8b7dd88d90e", "name": "update_config", - "func": "\n\nconst keys = global.keys(); \nlet config = {}; \n\nkeys.forEach(key => {\n if (!key.startsWith('$')) {\n if (\n key.startsWith('sample_') ||\n key.startsWith('acq_') ||\n key.startsWith('object_') ||\n key.startsWith('process_') ||\n key.startsWith('img_')\n ) {\n config[key] = global.get(key);\n }\n }\n});\n\nmsg.topic = \"imager/image\";\n\nmsg.payload = {\n action: \"update_config\",\n config: config\n};\n\nreturn msg;", + "func": "const keys = global.keys();\nlet config = {};\n\nkeys.forEach(key => {\n if (!key.startsWith('$')) {\n if (\n key.startsWith('sample_') ||\n key.startsWith('acq_') ||\n key.startsWith('object_') ||\n key.startsWith('process_') ||\n key.startsWith('img_')\n ) {\n config[key] = global.get(key);\n }\n }\n});\n\n// Compose fully-qualified IDs for unique EcoTaxa naming\nconst project = config.sample_project || \"\";\nconst sampleRaw = String(config.sample_id || \"\");\nconst acqRaw = String(config.acq_id || \"\");\n\nif (project && !sampleRaw.startsWith(project + \"_\")) {\n config.sample_id = project + \"_\" + sampleRaw;\n}\nconst finalSample = String(config.sample_id || \"\");\nif (finalSample && !acqRaw.startsWith(finalSample + \"_\")) {\n config.acq_id = finalSample + \"_\" + acqRaw;\n}\n\n// Sanitize spaces in IDs to match filesystem paths\n// (the Python imager replaces spaces with underscores in directory names)\nconfig.sample_id = String(config.sample_id || \"\").replace(/ /g, \"_\");\nconfig.acq_id = String(config.acq_id || \"\").replace(/ /g, \"_\");\n\nmsg.topic = \"imager/image\";\n\nmsg.payload = {\n action: \"update_config\",\n config: config\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5083,7 +5083,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "EXPLORER", - "func": "// Explorer Function Node\n// TSV → {meta, keys, data: [{id, url, ...keys}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\n// 1. Parse Headers\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const i = headers.indexOf(col);\n return i >= 0 ? row[i] : null;\n};\n\n// 2. Extract Meta from first valid data row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n // Skip rows starting with '[' (metadata comments)\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\n\n// Default meta\nconst meta = {\n sample_id: \"\",\n project: \"\",\n acq_id: \"\",\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = val(firstRow, \"sample_id\") || \"\";\n meta.project = val(firstRow, \"sample_project\") || \"\";\n meta.acq_id = val(firstRow, \"acq_id\") || \"\";\n \n // CRITICAL: Extract pixel size (microns per pixel)\n // If process_pixel is missing or 0, default to 1 to prevent division by zero\n const px = parseFloat(val(firstRow, \"process_pixel\"));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 3. Explorer Keys to extract\nconst explorerKeys = [\n \"object_area\", \"object_equivalent_diameter\", \"object_perim.\",\n \"object_major\", \"object_minor\", \"object_width\", \"object_height\",\n \"object_circ.\", \"object_elongation\", \"object_solidity\",\n \"object_eccentricity\", \"object_MeanHue\", \"object_MeanSaturation\",\n \"object_MeanValue\", \"object_StdValue\", \"object_blur_laplacian\"\n];\n\nlet seq = 0;\nconst data = [];\n\n// 4. Process Data Lines\nfor (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.trim().startsWith(\"[\")) continue;\n\n const row = line.split(\"\\t\");\n\n const item = {\n id: val(row, \"object_id\"),\n // Construct URL\n url: `/ps/node-red-v2/my-images/${val(row,\"object_date\")}/${val(row,\"sample_id\")}/${val(row,\"acq_id\")}/${val(row,\"img_file_name\")}`,\n sequence_index: seq++\n };\n\n // Parse numeric keys\n explorerKeys.forEach(k => {\n let v = parseFloat(val(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n // Custom calc for greenness (example)\n const hue = item.object_MeanHue || 0;\n const dist = Math.abs(hue - 80);\n item.custom_greenness = Math.max(0, 100 - dist);\n\n data.push(item);\n}\n\nmsg.payload = {\n meta,\n keys: explorerKeys,\n data\n};\n\nreturn msg;", + "func": "// Explorer Function Node\n// TSV → {meta, keys, data: [{id, url, ...keys}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\n// 1. Parse Headers\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const i = headers.indexOf(col);\n return i >= 0 ? row[i] : null;\n};\n\n// 2. Extract Meta from first valid data row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n // Skip rows starting with '[' (metadata comments)\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\n\n// Default meta\nconst meta = {\n sample_id: \"\",\n project: \"\",\n acq_id: \"\",\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = val(firstRow, \"sample_id\") || \"\";\n meta.project = val(firstRow, \"sample_project\") || \"\";\n meta.acq_id = val(firstRow, \"acq_id\") || \"\";\n \n // CRITICAL: Extract pixel size (microns per pixel)\n // If process_pixel is missing or 0, default to 1 to prevent division by zero\n const px = parseFloat(val(firstRow, \"process_pixel\"));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 3. Explorer Keys to extract\nconst explorerKeys = [\n \"object_area\", \"object_equivalent_diameter\", \"object_perim.\",\n \"object_major\", \"object_minor\", \"object_width\", \"object_height\",\n \"object_circ.\", \"object_elongation\", \"object_solidity\",\n \"object_eccentricity\", \"object_MeanHue\", \"object_MeanSaturation\",\n \"object_MeanValue\", \"object_StdValue\", \"object_blur_laplacian\"\n];\n\nlet seq = 0;\nconst data = [];\n\n// 4. Process Data Lines\nfor (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.trim().startsWith(\"[\")) continue;\n\n const row = line.split(\"\\t\");\n\n const item = {\n id: val(row, \"object_id\"),\n // Construct URL\n url: `/ps/node-red-v2/my-images/${(val(row,\"object_date\")||\"\").replace(/ /g,\"_\")}/${(val(row,\"sample_id\")||\"\").replace(/ /g,\"_\")}/${(val(row,\"acq_id\")||\"\").replace(/ /g,\"_\")}/${val(row,\"img_file_name\")}`,\n sequence_index: seq++\n };\n\n // Parse numeric keys\n explorerKeys.forEach(k => {\n let v = parseFloat(val(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n // Custom calc for greenness (example)\n const hue = item.object_MeanHue || 0;\n const dist = Math.abs(hue - 80);\n item.custom_greenness = Math.max(0, 100 - dist);\n\n data.push(item);\n}\n\nmsg.payload = {\n meta,\n keys: explorerKeys,\n data\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5158,7 +5158,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "GALLERY", - "func": "// GALLERY Function Node\n// TSV → { data: [], meta: { resolution: ... }, keys: ... }\n\n// 1. Handle Clear Signals\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 2. Validate Payload\nif (!msg.payload) return null;\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 3. Parse Headers\nconst headers = lines[0].split('\\t');\nconst get = (row, key) => {\n const idx = headers.indexOf(key);\n return idx >= 0 ? row[idx] : null;\n};\n\n// 4. Extract Meta (Resolution is key here)\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n const L = lines[i].trim();\n if (!L.startsWith('[') && L !== '') {\n firstRow = lines[i].split('\\t');\n break;\n }\n}\n\nconst meta = {\n sample_id: 'N/A',\n project: 'N/A',\n acq_id: 'N/A',\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = get(firstRow, 'sample_id') || \"\";\n meta.project = get(firstRow, 'sample_project') || \"\";\n meta.acq_id = get(firstRow, 'acq_id') || \"\";\n\n // CRITICAL: Extract microns per pixel\n const px = parseFloat(get(firstRow, 'process_pixel'));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 5. Select Keys to Display\nconst usefulKeys = [\n \"object_area\", \"object_width\", \"object_height\",\n \"object_equivalent_diameter\", \"object_major\", \"object_minor\",\n \"object_MeanHue\", \"object_elongation\", \"object_blur_laplacian\"\n];\n\nconst data = [];\n\n// 6. Process Rows\nfor (let i = 1; i < lines.length; i++) {\n const rowLine = lines[i].trim();\n if (rowLine.startsWith('[') || rowLine === '') continue;\n\n const row = rowLine.split('\\t');\n if (row.length !== headers.length) continue;\n\n const item = {\n id: get(row, 'object_id'),\n // Construct Image URL\n url: `/ps/data/browse/api/preview/big/objects/${get(row, 'object_date')}/${get(row, 'sample_id')}/${get(row, 'acq_id')}/${get(row, 'img_file_name')}`\n };\n\n // Parse numeric values\n usefulKeys.forEach(k => {\n let v = parseFloat(get(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n data.push(item);\n}\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta\n};\n\nreturn msg;", + "func": "// GALLERY Function Node\n// TSV → { data: [], meta: { resolution: ... }, keys: ... }\n\n// 1. Handle Clear Signals\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 2. Validate Payload\nif (!msg.payload) return null;\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 3. Parse Headers\nconst headers = lines[0].split('\\t');\nconst get = (row, key) => {\n const idx = headers.indexOf(key);\n return idx >= 0 ? row[idx] : null;\n};\n\n// 4. Extract Meta (Resolution is key here)\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n const L = lines[i].trim();\n if (!L.startsWith('[') && L !== '') {\n firstRow = lines[i].split('\\t');\n break;\n }\n}\n\nconst meta = {\n sample_id: 'N/A',\n project: 'N/A',\n acq_id: 'N/A',\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = get(firstRow, 'sample_id') || \"\";\n meta.project = get(firstRow, 'sample_project') || \"\";\n meta.acq_id = get(firstRow, 'acq_id') || \"\";\n\n // CRITICAL: Extract microns per pixel\n const px = parseFloat(get(firstRow, 'process_pixel'));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 5. Select Keys to Display\nconst usefulKeys = [\n \"object_area\", \"object_width\", \"object_height\",\n \"object_equivalent_diameter\", \"object_major\", \"object_minor\",\n \"object_MeanHue\", \"object_elongation\", \"object_blur_laplacian\"\n];\n\nconst data = [];\n\n// 6. Process Rows\nfor (let i = 1; i < lines.length; i++) {\n const rowLine = lines[i].trim();\n if (rowLine.startsWith('[') || rowLine === '') continue;\n\n const row = rowLine.split('\\t');\n if (row.length !== headers.length) continue;\n\n const item = {\n id: get(row, 'object_id'),\n // Construct Image URL\n url: `/ps/data/browse/api/preview/big/objects/${(get(row, 'object_date')||'').replace(/ /g,'_')}/${(get(row, 'sample_id')||'').replace(/ /g,'_')}/${(get(row, 'acq_id')||'').replace(/ /g,'_')}/${get(row, 'img_file_name')}`\n };\n\n // Parse numeric values\n usefulKeys.forEach(k => {\n let v = parseFloat(get(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n data.push(item);\n}\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, From 5336f068069b41688b4525c110315c7d17a06057 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 21:48:18 +0000 Subject: [PATCH 3/7] fix: add confirmation dialog before deleting acquisitions (#904) - Replace direct deleteItem with confirmDelete + dialog - Show acquisition and sample ID in confirmation prompt - Warning that deletion cannot be undone --- flows.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flows.json b/flows.json index 88c990d..5ef8e8b 100644 --- a/flows.json +++ b/flows.json @@ -4333,7 +4333,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n", + "format": "\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, From 86049e1cb3c09ba093d72ade17d06c05c03816ba Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 21:48:32 +0000 Subject: [PATCH 4/7] fix: cross-persist lat/lon and date/time in metadata setters (#907) - set object_datetime also persists lat/lon if present - set object_latlon also persists date/time if present --- flows.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flows.json b/flows.json index 5ef8e8b..3e324c9 100644 --- a/flows.json +++ b/flows.json @@ -3394,7 +3394,7 @@ "type": "function", "z": "f7ff9c0e54da4b8e", "name": "set object_latlon", - "func": "if (msg.topic) {\n global.set(\"object_lat\", msg.payload.object_lat);\n global.set(\"object_lon\", msg.payload.object_lon);\n}\nreturn msg;\n", + "func": "if (msg.topic) {\n global.set(\"object_lat\", msg.payload.object_lat);\n global.set(\"object_lon\", msg.payload.object_lon);\n if (msg.payload.object_date !== undefined) {\n global.set(\"object_date\", msg.payload.object_date);\n }\n if (msg.payload.object_time !== undefined) {\n global.set(\"object_time\", msg.payload.object_time);\n }\n}\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -3412,7 +3412,7 @@ "type": "function", "z": "f7ff9c0e54da4b8e", "name": "set object_datetime", - "func": "if (msg.topic) {\n global.set(\"object_date\", msg.payload.object_date);\n global.set(\"object_time\", msg.payload.object_time);\n}\nreturn msg;\n", + "func": "if (msg.topic) {\n global.set(\"object_date\", msg.payload.object_date);\n global.set(\"object_time\", msg.payload.object_time);\n if (msg.payload.object_lat !== undefined) {\n global.set(\"object_lat\", msg.payload.object_lat);\n }\n if (msg.payload.object_lon !== undefined) {\n global.set(\"object_lon\", msg.payload.object_lon);\n }\n}\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, From 8d051249bffbeda1b2809ca9487db3cefeb035c3 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 22:31:27 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20acquisition=20race=20condition=20?= =?UTF-8?q?=E2=80=94=20stale=20nb=5Fframe=20on=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The start acquisition function read acq_nb_frame and acq_interframe_volume from Node-RED globals, but the globals were written by a parallel message path. When the user changed form values and clicked Start, the read happened before the write, causing the acquisition to use stale values. Fix: start acquisition now reads from msg.payload first (carried with the command), falling back to globals for backwards compatibility. handleAcqStatusChange explicitly passes acq values in the start message. --- flows.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flows.json b/flows.json index 3e324c9..b4f9b0a 100644 --- a/flows.json +++ b/flows.json @@ -3686,7 +3686,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3864,7 +3864,7 @@ "type": "function", "z": "71ede8b7dd88d90e", "name": "start acquisition", - "func": "\n\nconst acq_interframe_volume = global.get(\"acq_interframe_volume\") || 0;\nconst acq_nb_frame = global.get(\"acq_nb_frame\") || 0;\nconst acq_stabilization_delay = global.get(\"acq_stabilization_delay\") || 0;\nconst acq_interframe_flowrate = global.get(\"acq_interframe_flowrate\") || 0;\n\nmsg.payload = {\n action: \"image\",\n pump_direction: \"FORWARD\",\n volume: acq_interframe_volume,\n nb_frame: acq_nb_frame,\n sleep: acq_stabilization_delay,\n flowrate: acq_interframe_flowrate,\n};\n\nmsg.topic = \"imager/image\";\n\nreturn msg;", + "func": "const acq_interframe_volume = msg.payload.acq_interframe_volume\n || global.get(\"acq_interframe_volume\") || 0;\nconst acq_nb_frame = msg.payload.acq_nb_frame\n || global.get(\"acq_nb_frame\") || 0;\nconst acq_stabilization_delay = msg.payload.acq_stabilization_delay\n || global.get(\"acq_stabilization_delay\") || 0;\nconst acq_interframe_flowrate = msg.payload.acq_interframe_flowrate\n || global.get(\"acq_interframe_flowrate\") || 0;\n\nmsg.payload = {\n action: \"image\",\n pump_direction: \"FORWARD\",\n volume: acq_interframe_volume,\n nb_frame: acq_nb_frame,\n sleep: acq_stabilization_delay,\n flowrate: acq_interframe_flowrate,\n};\n\nmsg.topic = \"imager/image\";\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, From 1402afb37fe67bfebf5bf78dfcd5700db602283a Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Thu, 19 Mar 2026 02:58:10 +0000 Subject: [PATCH 6/7] feat: streaming TSV reader and columnar chart nodes Replace full-file TSV loading with streaming columnar reader that extracts only the 24 columns used by charts. Memory usage drops from ~500MB to ~9MB for 56K-row datasets. Chart node improvements: - Scatter plots (heatmap, colorspace, aspect, greenness, complexity, texture) use reservoir sampling capped at 10K points - Histograms (ESD, solidity) pass all values for accurate binning - Gallery and explorer pre-sort by area descending and cap at 5K rows - Timeline preserves full temporal resolution --- flows.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/flows.json b/flows.json index b4f9b0a..8dbbd33 100644 --- a/flows.json +++ b/flows.json @@ -5473,10 +5473,10 @@ "id": "1802e96dd833363b", "type": "function", "z": "a7825c4c81ad20a0", - "name": "Size-safe file read", - "func": "const MAX_LINES = 5001;\nconst MAX_SIZE = 10 * 1024 * 1024;\n\nconst filePath = msg.payload.path || msg.filename;\nif (!filePath) { return null; }\n\ntry {\n const stats = fs.statSync(filePath);\n if (stats.size > MAX_SIZE) {\n const fd = fs.openSync(filePath, \"r\");\n const buf = Buffer.alloc(Math.min(stats.size, 2 * 1024 * 1024));\n const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);\n fs.closeSync(fd);\n const partial = buf.toString(\"utf8\", 0, bytesRead);\n const lines = partial.split(\"\\n\");\n msg.payload = lines.slice(0, MAX_LINES).join(\"\\n\");\n node.warn(\"Large TSV (\" + Math.round(stats.size/1024/1024) + \"MB) - limited to \" + MAX_LINES + \" lines\");\n } else {\n msg.payload = fs.readFileSync(filePath, \"utf8\");\n }\n return msg;\n} catch(e) {\n node.error(\"File read error: \" + e.message, msg);\n return null;\n}", + "name": "Streaming TSV reader", + "func": "// Streaming TSV reader — extracts only needed columns, never loads full file\nconst filePath = msg.payload.path || msg.filename;\nif (!filePath) return null;\n\nconst NEEDED = [\n 'object_x','object_y','object_equivalent_diameter','object_area',\n 'object_MeanSaturation','object_MeanValue','object_width','object_height',\n 'object_MeanHue','object_circ.','object_perim.','object_StdValue',\n 'object_solidity','object_elongation','object_major','object_minor',\n 'object_eccentricity','object_id','object_date','sample_id',\n 'sample_project','acq_id','img_file_name','process_pixel'\n];\n\nreturn new Promise((resolve) => {\n const columns = {};\n NEEDED.forEach(k => columns[k] = []);\n const colIdx = {};\n const meta = {};\n let totalRows = 0;\n let lineNum = 0;\n let remainder = '';\n\n const stream = fs.createReadStream(filePath, { encoding: 'utf8' });\n\n stream.on('data', chunk => {\n remainder += chunk;\n const lines = remainder.split('\\n');\n remainder = lines.pop();\n\n for (const line of lines) {\n if (!line.trim()) continue;\n lineNum++;\n\n if (lineNum === 1) {\n const headers = line.split('\\t');\n headers.forEach((h, i) => { if (NEEDED.includes(h)) colIdx[h] = i; });\n continue;\n }\n if (line.trim().startsWith('[')) continue;\n\n const fields = line.split('\\t');\n totalRows++;\n\n if (totalRows === 1) {\n meta.sample_id = fields[colIdx['sample_id']] || '';\n meta.sample_project = fields[colIdx['sample_project']] || '';\n meta.acq_id = fields[colIdx['acq_id']] || '';\n meta.process_pixel = fields[colIdx['process_pixel']] || '';\n }\n\n for (const key of NEEDED) {\n if (colIdx[key] !== undefined) {\n columns[key].push(fields[colIdx[key]]);\n }\n }\n }\n });\n\n stream.on('end', () => {\n if (remainder.trim() && !remainder.trim().startsWith('[') && lineNum > 0) {\n const fields = remainder.split('\\t');\n totalRows++;\n for (const key of NEEDED) {\n if (colIdx[key] !== undefined) {\n columns[key].push(fields[colIdx[key]]);\n }\n }\n }\n\n const sid = meta.sample_id || '';\n const proj = meta.sample_project || '';\n meta.display_sample_id = sid.split(proj + '_')[1] || sid;\n meta.display_acq_id = (meta.acq_id || '').split(meta.display_sample_id + '_')[1] || meta.acq_id;\n\n msg.payload = { columns, meta, totalRows };\n resolve(msg);\n });\n\n stream.on('error', err => {\n node.error('TSV stream error: ' + err.message, msg);\n resolve(null);\n });\n});", "outputs": 1, - "timeout": 0, + "timeout": 30, "noerr": 0, "initialize": "", "finalize": "", @@ -5611,7 +5611,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "HEATMAP (object_x, object_y)", - "func": "// Heatmap – Flowcell distribution\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\n// Meta from first valid row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nif (!firstRow) return null;\n\nconst meta = {\n sample_id: val(firstRow, \"sample_id\") || \"\",\n project: val(firstRow, \"sample_project\") || \"\",\n acq_id: val(firstRow, \"acq_id\") || \"\"\n};\n\n// Data: x/y for heatmap\nconst data = lines.slice(1)\n .filter(l => !l.trim().startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n x: parseFloat(val(r, \"object_x\")),\n y: parseFloat(val(r, \"object_y\"))\n };\n });\n\nmsg.payload = { meta, data };\nreturn msg;\n", + "func": "// Heatmap — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_x'];\nconst yCol = columns['object_y'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { meta, data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5631,7 +5631,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "ESD Histogram (object_equivalent_diameter)", - "func": "// ESD Size Spectrum – TSV → {meta, data[{d}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nconst meta = {\n sample_id: firstRow ? (val(firstRow, \"sample_id\") || \"\") : \"\"\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n d: parseFloat(val(r, \"object_equivalent_diameter\"))\n };\n });\n\nmsg.payload = { meta, data };\nreturn msg;\n", + "func": "// ESD Histogram — columnar input, all values for accurate histogram\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_equivalent_diameter'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { d: parseFloat(raw[i]) };\n}\n\nmsg.payload = { meta, data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5651,7 +5651,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "TIMELINE (sequence index + area)", - "func": "// Timeline – seq index vs object_area\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nlet seq = 0;\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n seq: seq++,\n area: parseFloat(val(r, \"object_area\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Timeline — columnar input, all values\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_area'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { seq: i, area: parseFloat(raw[i]) };\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5671,7 +5671,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "COLORSPACE (Saturation vs Value)", - "func": "// Colorspace – MeanSaturation vs MeanValue\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n s: parseFloat(val(r, \"object_MeanSaturation\")),\n v: parseFloat(val(r, \"object_MeanValue\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Colorspace — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_MeanSaturation'];\nconst yCol = columns['object_MeanValue'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5691,7 +5691,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "ASPECT (Width vs Height)", - "func": "// Aspect – width vs height\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n w: parseFloat(val(r, \"object_width\")),\n h: parseFloat(val(r, \"object_height\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Aspect — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_width'];\nconst yCol = columns['object_height'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5711,7 +5711,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "GREENNESS (custom index + circularity)", - "func": "// Greenness vs Circularity – custom_greenness + object_circ.\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n const hue = parseFloat(val(r, \"object_MeanHue\")) || 0;\n const circ = parseFloat(val(r, \"object_circ.\")) || 0;\n const greenDist = Math.abs(hue - 80);\n const g = Math.max(0, 100 - greenDist);\n return { g, circ };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Greenness — columnar input with reservoir sampling + derived greenness\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst hueCol = columns['object_MeanHue'];\nconst circCol = columns['object_circ.'];\nif (!hueCol || !circCol) return null;\nconst n = hueCol.length;\n\nfunction makePoint(i) {\n const hue = parseFloat(hueCol[i]) || 0;\n const circ = parseFloat(circCol[i]) || 0;\n const g = Math.max(0, 100 - Math.abs(hue - 80));\n return { g, circ };\n}\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) data[i] = makePoint(i);\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) data[i] = makePoint(i);\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) data[j] = makePoint(i);\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5731,7 +5731,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "COMPLEXITY (Area vs Perimeter)", - "func": "// Complexity – Area vs Perimeter\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n area: parseFloat(val(r, \"object_area\")),\n per: parseFloat(val(r, \"object_perim.\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Complexity — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_area'];\nconst yCol = columns['object_perim.'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5751,7 +5751,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "TEXTURE (Area vs StdValue)", - "func": "// Texture – Area vs StdValue\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n area: parseFloat(val(r, \"object_area\")),\n std: parseFloat(val(r, \"object_StdValue\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Texture — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_area'];\nconst yCol = columns['object_StdValue'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5771,7 +5771,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "SOLIDITY (Histogram)", - "func": "// Solidity – histogram of object_solidity\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n sol: parseFloat(val(r, \"object_solidity\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Solidity Histogram — columnar input, all values\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_solidity'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { sol: parseFloat(raw[i]) };\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5815,7 +5815,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "Sample Identity Metadata Only", - "func": "// FUNCTION: Extract minimal metadata for the “Sample Identity” panel\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\n// --- 1. Find first data row (skip [f] / [t] metadata-like lines) ---\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nif (!firstRow) return null;\n\n// --- 2. Extract minimal metadata ---\nconst meta = {\n sample_id: val(firstRow, \"sample_id\") || \"\",\n project: val(firstRow, \"sample_project\") || \"\",\n acq_id: val(firstRow, \"acq_id\") || \"\"\n};\n\n// --- 3. Output ONLY the metadata ---\nmsg.payload = { meta };\nreturn msg;\n", + "func": "// Sample Identity — extract metadata from columnar input\nconst { meta } = msg.payload;\nmsg.payload = { meta: {\n sample_id: meta.sample_id || '',\n project: meta.sample_project || '',\n acq_id: meta.acq_id || ''\n}};\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6027,7 +6027,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "EXPLORER", - "func": "// Explorer Function Node\n// TSV → {meta, keys, data: [{id, url, ...keys}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\n// 1. Parse Headers\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const i = headers.indexOf(col);\n return i >= 0 ? row[i] : null;\n};\n\n// 2. Extract Meta from first valid data row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n // Skip rows starting with '[' (metadata comments)\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\n\n// Default meta\nconst meta = {\n sample_id: \"\",\n project: \"\",\n acq_id: \"\",\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = val(firstRow, \"sample_id\") || \"\";\n meta.project = val(firstRow, \"sample_project\") || \"\";\n meta.acq_id = val(firstRow, \"acq_id\") || \"\";\n \n // CRITICAL: Extract pixel size (microns per pixel)\n // If process_pixel is missing or 0, default to 1 to prevent division by zero\n const px = parseFloat(val(firstRow, \"process_pixel\"));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 3. Explorer Keys to extract\nconst explorerKeys = [\n \"object_area\", \"object_equivalent_diameter\", \"object_perim.\",\n \"object_major\", \"object_minor\", \"object_width\", \"object_height\",\n \"object_circ.\", \"object_elongation\", \"object_solidity\",\n \"object_eccentricity\", \"object_MeanHue\", \"object_MeanSaturation\",\n \"object_MeanValue\", \"object_StdValue\", \"object_blur_laplacian\"\n];\n\nlet seq = 0;\nconst data = [];\n\n// 4. Process Data Lines\nfor (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.trim().startsWith(\"[\")) continue;\n\n const row = line.split(\"\\t\");\n\n const item = {\n id: val(row, \"object_id\"),\n // Construct URL\n url: msg.tsvDir ? `/ps/node-red-v2/my-images/${msg.tsvDir}/${val(row,\"img_file_name\")}` : `/ps/node-red-v2/my-images/${val(row,\"object_date\")}/${val(row,\"sample_id\")}/${val(row,\"acq_id\")}/${val(row,\"img_file_name\")}`,\n sequence_index: seq++\n };\n\n // Parse numeric keys\n explorerKeys.forEach(k => {\n let v = parseFloat(val(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n // Custom calc for greenness (example)\n const hue = item.object_MeanHue || 0;\n const dist = Math.abs(hue - 80);\n item.custom_greenness = Math.max(0, 100 - dist);\n\n data.push(item);\n}\n\n// Cap rows to prevent browser crash on huge datasets\nconst MAX_ROWS = 5000;\nif (data.length > MAX_ROWS) data.length = MAX_ROWS;\n\nmsg.payload = {\n meta,\n keys: explorerKeys,\n data\n};\n\nreturn msg;", + "func": "// Explorer — columnar input, pre-sorted by area (largest first), capped at 5000\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_ROWS = 5000;\nconst idCol = columns['object_id'];\nif (!idCol) return null;\n\nconst explorerKeys = [\n \"object_area\",\"object_equivalent_diameter\",\"object_perim.\",\n \"object_major\",\"object_minor\",\"object_width\",\"object_height\",\n \"object_circ.\",\"object_elongation\",\"object_solidity\",\n \"object_eccentricity\",\"object_MeanHue\",\"object_MeanSaturation\",\n \"object_MeanValue\",\"object_StdValue\"\n];\n\nconst px = parseFloat(meta.process_pixel);\nconst resolution = (!isNaN(px) && px > 0) ? px : 1.0;\n\n// Build all rows first (lightweight: ~200 bytes each)\nconst allRows = [];\nfor (let i = 0; i < idCol.length; i++) {\n const item = {\n id: columns['object_id'][i],\n url: `/ps/node-red-v2/my-images/${(columns['object_date'][i]||'').replace(/ /g,'_')}/${(columns['sample_id'][i]||'').replace(/ /g,'_')}/${(columns['acq_id'][i]||'').replace(/ /g,'_')}/${columns['img_file_name'][i]}`,\n sequence_index: i\n };\n explorerKeys.forEach(k => {\n const v = parseFloat((columns[k] || [])[i]);\n item[k] = isNaN(v) ? 0 : v;\n });\n const hue = item.object_MeanHue || 0;\n item.custom_greenness = Math.max(0, 100 - Math.abs(hue - 80));\n allRows.push(item);\n}\n\n// Pre-sort by area descending (the default sort in the UI)\nallRows.sort((a, b) => b.object_area - a.object_area);\n\n// Cap to MAX_ROWS\nconst data = allRows.length > MAX_ROWS ? allRows.slice(0, MAX_ROWS) : allRows;\n\nmsg.payload = {\n meta: { sample_id: meta.sample_id, project: meta.sample_project, acq_id: meta.acq_id, resolution },\n keys: explorerKeys,\n data,\n totalRows,\n showing: data.length\n};\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6102,7 +6102,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "GALLERY", - "func": "// GALLERY Function Node\n// TSV → { data: [], meta: { resolution: ... }, keys: ... }\n\n// 1. Handle Clear Signals\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 2. Validate Payload\nif (!msg.payload) return null;\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 3. Parse Headers\nconst headers = lines[0].split('\\t');\nconst get = (row, key) => {\n const idx = headers.indexOf(key);\n return idx >= 0 ? row[idx] : null;\n};\n\n// 4. Extract Meta (Resolution is key here)\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n const L = lines[i].trim();\n if (!L.startsWith('[') && L !== '') {\n firstRow = lines[i].split('\\t');\n break;\n }\n}\n\nconst meta = {\n sample_id: 'N/A',\n project: 'N/A',\n acq_id: 'N/A',\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = get(firstRow, 'sample_id') || \"\";\n meta.project = get(firstRow, 'sample_project') || \"\";\n meta.acq_id = get(firstRow, 'acq_id') || \"\";\n\n // CRITICAL: Extract microns per pixel\n const px = parseFloat(get(firstRow, 'process_pixel'));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 5. Select Keys to Display\nconst usefulKeys = [\n \"object_area\", \"object_width\", \"object_height\",\n \"object_equivalent_diameter\", \"object_major\", \"object_minor\",\n \"object_MeanHue\", \"object_elongation\", \"object_blur_laplacian\"\n];\n\nconst data = [];\n\n// 6. Process Rows\nfor (let i = 1; i < lines.length; i++) {\n const rowLine = lines[i].trim();\n if (rowLine.startsWith('[') || rowLine === '') continue;\n\n const row = rowLine.split('\\t');\n if (row.length !== headers.length) continue;\n\n const item = {\n id: get(row, 'object_id'),\n // Construct Image URL\n url: msg.tsvDir ? `/ps/node-red-v2/my-images/${msg.tsvDir}/${get(row, 'img_file_name')}` : `/ps/node-red-v2/my-images/${get(row, 'object_date')}/${get(row, 'sample_id')}/${get(row, 'acq_id')}/${get(row, 'img_file_name')}`\n };\n\n // Parse numeric values\n usefulKeys.forEach(k => {\n let v = parseFloat(get(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n data.push(item);\n}\n\n// Cap rows to prevent browser crash on huge datasets\nconst MAX_ROWS = 5000;\nif (data.length > MAX_ROWS) data.length = MAX_ROWS;\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta\n};\n\nreturn msg;", + "func": "// Gallery — columnar input, pre-sorted by area (largest first), capped at 5000\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst { columns, meta, totalRows } = msg.payload;\nif (!columns) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst MAX_ROWS = 5000;\nconst idCol = columns['object_id'];\nif (!idCol) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst usefulKeys = [\n \"object_area\",\"object_width\",\"object_height\",\n \"object_equivalent_diameter\",\"object_major\",\"object_minor\",\n \"object_MeanHue\",\"object_elongation\"\n];\n\nconst px = parseFloat(meta.process_pixel);\nconst resolution = (!isNaN(px) && px > 0) ? px : 1.0;\n\n// Build all rows\nconst allRows = [];\nfor (let i = 0; i < idCol.length; i++) {\n const item = {\n id: columns['object_id'][i],\n url: `/ps/node-red-v2/my-images/${(columns['object_date'][i]||'').replace(/ /g,'_')}/${(columns['sample_id'][i]||'').replace(/ /g,'_')}/${(columns['acq_id'][i]||'').replace(/ /g,'_')}/${columns['img_file_name'][i]}`\n };\n usefulKeys.forEach(k => {\n const v = parseFloat((columns[k] || [])[i]);\n item[k] = isNaN(v) ? 0 : v;\n });\n allRows.push(item);\n}\n\n// Pre-sort by area descending (default sort in UI)\nallRows.sort((a, b) => b.object_area - a.object_area);\n\n// Cap to MAX_ROWS\nconst data = allRows.length > MAX_ROWS ? allRows.slice(0, MAX_ROWS) : allRows;\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta: { sample_id: meta.sample_id, project: meta.sample_project, acq_id: meta.acq_id, resolution },\n totalRows,\n showing: data.length\n};\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, From e7114052f384862ddf222f4ad93c06741fcf24cf Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Thu, 19 Mar 2026 03:54:35 +0000 Subject: [PATCH 7/7] fix: add object_blur_laplacian to streaming reader, gallery, and explorer The columnar streaming reader only extracts listed columns. The blur metric was missing, causing Sort by Sharpest to have no effect in the gallery and explorer views. --- flows.json | 116 ++++++++++++++++++++++++++--------------------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/flows.json b/flows.json index 8dbbd33..503dfaf 100644 --- a/flows.json +++ b/flows.json @@ -2152,7 +2152,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n", + "format": "\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -2406,7 +2406,7 @@ "width": "12", "height": "6", "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -2648,7 +2648,7 @@ "width": "0", "height": "0", "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -2857,7 +2857,7 @@ "width": "0", "height": "0", "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -2916,7 +2916,7 @@ "type": "function", "z": "6d6a011bf1913637", "name": "set light settings", - "func": "// On vérifie si le message contient les propriétés attendues\n// avant de mettre à jour les variables globales.\n\nif (msg.payload) {\n \n // Si led_status est présent dans le payload, on stocke\n if (msg.payload.led_status !== undefined) {\n global.set(\"led_status\", msg.payload.led_status);\n }\n\n // Si calibration_led_intensity est présent dans le payload, on stocke\n if (msg.payload.calibration_led_intensity !== undefined) {\n global.set(\"calibration_led_intensity\", msg.payload.calibration_led_intensity);\n }\n}\n\nreturn msg;", + "func": "// On v\u00e9rifie si le message contient les propri\u00e9t\u00e9s attendues\n// avant de mettre \u00e0 jour les variables globales.\n\nif (msg.payload) {\n \n // Si led_status est pr\u00e9sent dans le payload, on stocke\n if (msg.payload.led_status !== undefined) {\n global.set(\"led_status\", msg.payload.led_status);\n }\n\n // Si calibration_led_intensity est pr\u00e9sent dans le payload, on stocke\n if (msg.payload.calibration_led_intensity !== undefined) {\n global.set(\"calibration_led_intensity\", msg.payload.calibration_led_intensity);\n }\n}\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -3019,7 +3019,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3150,7 +3150,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3201,7 +3201,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n", + "format": "\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3227,7 +3227,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3253,7 +3253,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3279,7 +3279,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3662,7 +3662,7 @@ "width": "0", "height": "0", "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3686,7 +3686,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3712,7 +3712,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3729,7 +3729,7 @@ "type": "function", "z": "71ede8b7dd88d90e", "name": "set acq_params", - "func": "// On récupère le payload\nconst p = msg.payload;\n\n// On ne procède que si le payload est un objet\nif (p && typeof p === 'object') {\n\n // Liste des paramètres à surveiller et à stocker\n // On boucle sur les clés pour éviter de répéter 20 fois \"if(p.x !== undefined)\"\n const keys = [\n \"acq_id\", \"acq_nb_frame\", \"acq_interframe_volume\", \n \"acq_imaged_volume\", \"acq_pumped_volume\", \"acq_comment\",\n \"acq_status\", \"acq_magnification\", \"acq_tube_lens_reference\",\n \"acq_objective_lens_reference\", \"process_pixel_size\",\n \"calibration_pixel_size\", \"calibration_led_intensity\",\n \"sensor_width_um\", \"sensor_height_um\", \"acq_flowcell_thickness\",\n \"acq_interframe_flowrate\", \"acq_stabilization_delay\",\n \"acq_start_timestamp\"\n ];\n\n keys.forEach(key => {\n if (p[key] !== undefined) {\n global.set(key, p[key]);\n }\n });\n\n // Cas particuliers (noms de propriétés différents entre msg et global)\n if (p.progression !== undefined) global.set(\"acq_progression\", p.progression);\n if (p.duration_left !== undefined) global.set(\"acq_duration_left\", p.duration_left);\n}\n\nreturn msg;", + "func": "// On r\u00e9cup\u00e8re le payload\nconst p = msg.payload;\n\n// On ne proc\u00e8de que si le payload est un objet\nif (p && typeof p === 'object') {\n\n // Liste des param\u00e8tres \u00e0 surveiller et \u00e0 stocker\n // On boucle sur les cl\u00e9s pour \u00e9viter de r\u00e9p\u00e9ter 20 fois \"if(p.x !== undefined)\"\n const keys = [\n \"acq_id\", \"acq_nb_frame\", \"acq_interframe_volume\", \n \"acq_imaged_volume\", \"acq_pumped_volume\", \"acq_comment\",\n \"acq_status\", \"acq_magnification\", \"acq_tube_lens_reference\",\n \"acq_objective_lens_reference\", \"process_pixel_size\",\n \"calibration_pixel_size\", \"calibration_led_intensity\",\n \"sensor_width_um\", \"sensor_height_um\", \"acq_flowcell_thickness\",\n \"acq_interframe_flowrate\", \"acq_stabilization_delay\",\n \"acq_start_timestamp\"\n ];\n\n keys.forEach(key => {\n if (p[key] !== undefined) {\n global.set(key, p[key]);\n }\n });\n\n // Cas particuliers (noms de propri\u00e9t\u00e9s diff\u00e9rents entre msg et global)\n if (p.progression !== undefined) global.set(\"acq_progression\", p.progression);\n if (p.duration_left !== undefined) global.set(\"acq_duration_left\", p.duration_left);\n}\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -4110,7 +4110,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -4198,7 +4198,7 @@ "width": "0", "height": "0", "head": "", - "format": "\n\n\n\n\n", + "format": "\n\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -4307,7 +4307,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n", + "format": "\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -4452,7 +4452,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -4482,7 +4482,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "Insert export column", - "func": "msg.payload = msg.payload.map(item => {\n // extraction de l'acquisition_id\n const acq = item.acquisition_id; // ex: \"A_2\"\n\n // création du chemin export\n item.export = `/ps/data/browse/api/raw/export/ecotaxa/ecotaxa_${acq}.zip`;\n\n return item;\n});\nreturn msg;\n", + "func": "msg.payload = msg.payload.map(item => {\n // extraction de l'acquisition_id\n const acq = item.acquisition_id; // ex: \"A_2\"\n\n // cr\u00e9ation du chemin export\n item.export = `/ps/data/browse/api/raw/export/ecotaxa/ecotaxa_${acq}.zip`;\n\n return item;\n});\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4567,7 +4567,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n\n\n", + "format": "\n\n\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -4667,7 +4667,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "HEATMAP (object_x, object_y)", - "func": "// Heatmap – Flowcell distribution\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\n// Meta from first valid row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nif (!firstRow) return null;\n\nconst meta = {\n sample_id: val(firstRow, \"sample_id\") || \"\",\n project: val(firstRow, \"sample_project\") || \"\",\n acq_id: val(firstRow, \"acq_id\") || \"\"\n};\n\n// Data: x/y for heatmap\nconst data = lines.slice(1)\n .filter(l => !l.trim().startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n x: parseFloat(val(r, \"object_x\")),\n y: parseFloat(val(r, \"object_y\"))\n };\n });\n\nmsg.payload = { meta, data };\nreturn msg;\n", + "func": "// Heatmap \u2013 Flowcell distribution\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\n// Meta from first valid row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nif (!firstRow) return null;\n\nconst meta = {\n sample_id: val(firstRow, \"sample_id\") || \"\",\n project: val(firstRow, \"sample_project\") || \"\",\n acq_id: val(firstRow, \"acq_id\") || \"\"\n};\n\n// Data: x/y for heatmap\nconst data = lines.slice(1)\n .filter(l => !l.trim().startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n x: parseFloat(val(r, \"object_x\")),\n y: parseFloat(val(r, \"object_y\"))\n };\n });\n\nmsg.payload = { meta, data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4687,7 +4687,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "ESD Histogram (object_equivalent_diameter)", - "func": "// ESD Size Spectrum – TSV → {meta, data[{d}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nconst meta = {\n sample_id: firstRow ? (val(firstRow, \"sample_id\") || \"\") : \"\"\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n d: parseFloat(val(r, \"object_equivalent_diameter\"))\n };\n });\n\nmsg.payload = { meta, data };\nreturn msg;\n", + "func": "// ESD Size Spectrum \u2013 TSV \u2192 {meta, data[{d}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nconst meta = {\n sample_id: firstRow ? (val(firstRow, \"sample_id\") || \"\") : \"\"\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n d: parseFloat(val(r, \"object_equivalent_diameter\"))\n };\n });\n\nmsg.payload = { meta, data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4707,7 +4707,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "TIMELINE (sequence index + area)", - "func": "// Timeline – seq index vs object_area\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nlet seq = 0;\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n seq: seq++,\n area: parseFloat(val(r, \"object_area\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Timeline \u2013 seq index vs object_area\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nlet seq = 0;\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n seq: seq++,\n area: parseFloat(val(r, \"object_area\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4727,7 +4727,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "COLORSPACE (Saturation vs Value)", - "func": "// Colorspace – MeanSaturation vs MeanValue\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n s: parseFloat(val(r, \"object_MeanSaturation\")),\n v: parseFloat(val(r, \"object_MeanValue\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Colorspace \u2013 MeanSaturation vs MeanValue\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n s: parseFloat(val(r, \"object_MeanSaturation\")),\n v: parseFloat(val(r, \"object_MeanValue\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4747,7 +4747,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "ASPECT (Width vs Height)", - "func": "// Aspect – width vs height\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n w: parseFloat(val(r, \"object_width\")),\n h: parseFloat(val(r, \"object_height\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Aspect \u2013 width vs height\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n w: parseFloat(val(r, \"object_width\")),\n h: parseFloat(val(r, \"object_height\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4767,7 +4767,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "GREENNESS (custom index + circularity)", - "func": "// Greenness vs Circularity – custom_greenness + object_circ.\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n const hue = parseFloat(val(r, \"object_MeanHue\")) || 0;\n const circ = parseFloat(val(r, \"object_circ.\")) || 0;\n const greenDist = Math.abs(hue - 80);\n const g = Math.max(0, 100 - greenDist);\n return { g, circ };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Greenness vs Circularity \u2013 custom_greenness + object_circ.\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n const hue = parseFloat(val(r, \"object_MeanHue\")) || 0;\n const circ = parseFloat(val(r, \"object_circ.\")) || 0;\n const greenDist = Math.abs(hue - 80);\n const g = Math.max(0, 100 - greenDist);\n return { g, circ };\n });\n\nmsg.payload = { data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4787,7 +4787,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "COMPLEXITY (Area vs Perimeter)", - "func": "// Complexity – Area vs Perimeter\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n area: parseFloat(val(r, \"object_area\")),\n per: parseFloat(val(r, \"object_perim.\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Complexity \u2013 Area vs Perimeter\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n area: parseFloat(val(r, \"object_area\")),\n per: parseFloat(val(r, \"object_perim.\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4807,7 +4807,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "TEXTURE (Area vs StdValue)", - "func": "// Texture – Area vs StdValue\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n area: parseFloat(val(r, \"object_area\")),\n std: parseFloat(val(r, \"object_StdValue\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Texture \u2013 Area vs StdValue\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n area: parseFloat(val(r, \"object_area\")),\n std: parseFloat(val(r, \"object_StdValue\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4827,7 +4827,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "SOLIDITY (Histogram)", - "func": "// Solidity – histogram of object_solidity\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n sol: parseFloat(val(r, \"object_solidity\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", + "func": "// Solidity \u2013 histogram of object_solidity\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n sol: parseFloat(val(r, \"object_solidity\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4871,7 +4871,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "Sample Identity Metadata Only", - "func": "// FUNCTION: Extract minimal metadata for the “Sample Identity” panel\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\n// --- 1. Find first data row (skip [f] / [t] metadata-like lines) ---\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nif (!firstRow) return null;\n\n// --- 2. Extract minimal metadata ---\nconst meta = {\n sample_id: val(firstRow, \"sample_id\") || \"\",\n project: val(firstRow, \"sample_project\") || \"\",\n acq_id: val(firstRow, \"acq_id\") || \"\"\n};\n\n// --- 3. Output ONLY the metadata ---\nmsg.payload = { meta };\nreturn msg;\n", + "func": "// FUNCTION: Extract minimal metadata for the \u201cSample Identity\u201d panel\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\n// --- 1. Find first data row (skip [f] / [t] metadata-like lines) ---\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nif (!firstRow) return null;\n\n// --- 2. Extract minimal metadata ---\nconst meta = {\n sample_id: val(firstRow, \"sample_id\") || \"\",\n project: val(firstRow, \"sample_project\") || \"\",\n acq_id: val(firstRow, \"acq_id\") || \"\"\n};\n\n// --- 3. Output ONLY the metadata ---\nmsg.payload = { meta };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5083,7 +5083,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "EXPLORER", - "func": "// Explorer Function Node\n// TSV → {meta, keys, data: [{id, url, ...keys}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\n// 1. Parse Headers\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const i = headers.indexOf(col);\n return i >= 0 ? row[i] : null;\n};\n\n// 2. Extract Meta from first valid data row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n // Skip rows starting with '[' (metadata comments)\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\n\n// Default meta\nconst meta = {\n sample_id: \"\",\n project: \"\",\n acq_id: \"\",\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = val(firstRow, \"sample_id\") || \"\";\n meta.project = val(firstRow, \"sample_project\") || \"\";\n meta.acq_id = val(firstRow, \"acq_id\") || \"\";\n \n // CRITICAL: Extract pixel size (microns per pixel)\n // If process_pixel is missing or 0, default to 1 to prevent division by zero\n const px = parseFloat(val(firstRow, \"process_pixel\"));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 3. Explorer Keys to extract\nconst explorerKeys = [\n \"object_area\", \"object_equivalent_diameter\", \"object_perim.\",\n \"object_major\", \"object_minor\", \"object_width\", \"object_height\",\n \"object_circ.\", \"object_elongation\", \"object_solidity\",\n \"object_eccentricity\", \"object_MeanHue\", \"object_MeanSaturation\",\n \"object_MeanValue\", \"object_StdValue\", \"object_blur_laplacian\"\n];\n\nlet seq = 0;\nconst data = [];\n\n// 4. Process Data Lines\nfor (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.trim().startsWith(\"[\")) continue;\n\n const row = line.split(\"\\t\");\n\n const item = {\n id: val(row, \"object_id\"),\n // Construct URL\n url: `/ps/node-red-v2/my-images/${(val(row,\"object_date\")||\"\").replace(/ /g,\"_\")}/${(val(row,\"sample_id\")||\"\").replace(/ /g,\"_\")}/${(val(row,\"acq_id\")||\"\").replace(/ /g,\"_\")}/${val(row,\"img_file_name\")}`,\n sequence_index: seq++\n };\n\n // Parse numeric keys\n explorerKeys.forEach(k => {\n let v = parseFloat(val(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n // Custom calc for greenness (example)\n const hue = item.object_MeanHue || 0;\n const dist = Math.abs(hue - 80);\n item.custom_greenness = Math.max(0, 100 - dist);\n\n data.push(item);\n}\n\nmsg.payload = {\n meta,\n keys: explorerKeys,\n data\n};\n\nreturn msg;", + "func": "// Explorer Function Node\n// TSV \u2192 {meta, keys, data: [{id, url, ...keys}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\n// 1. Parse Headers\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const i = headers.indexOf(col);\n return i >= 0 ? row[i] : null;\n};\n\n// 2. Extract Meta from first valid data row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n // Skip rows starting with '[' (metadata comments)\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\n\n// Default meta\nconst meta = {\n sample_id: \"\",\n project: \"\",\n acq_id: \"\",\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = val(firstRow, \"sample_id\") || \"\";\n meta.project = val(firstRow, \"sample_project\") || \"\";\n meta.acq_id = val(firstRow, \"acq_id\") || \"\";\n \n // CRITICAL: Extract pixel size (microns per pixel)\n // If process_pixel is missing or 0, default to 1 to prevent division by zero\n const px = parseFloat(val(firstRow, \"process_pixel\"));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 3. Explorer Keys to extract\nconst explorerKeys = [\n \"object_area\", \"object_equivalent_diameter\", \"object_perim.\",\n \"object_major\", \"object_minor\", \"object_width\", \"object_height\",\n \"object_circ.\", \"object_elongation\", \"object_solidity\",\n \"object_eccentricity\", \"object_MeanHue\", \"object_MeanSaturation\",\n \"object_MeanValue\", \"object_StdValue\", \"object_blur_laplacian\"\n];\n\nlet seq = 0;\nconst data = [];\n\n// 4. Process Data Lines\nfor (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.trim().startsWith(\"[\")) continue;\n\n const row = line.split(\"\\t\");\n\n const item = {\n id: val(row, \"object_id\"),\n // Construct URL\n url: `/ps/node-red-v2/my-images/${(val(row,\"object_date\")||\"\").replace(/ /g,\"_\")}/${(val(row,\"sample_id\")||\"\").replace(/ /g,\"_\")}/${(val(row,\"acq_id\")||\"\").replace(/ /g,\"_\")}/${val(row,\"img_file_name\")}`,\n sequence_index: seq++\n };\n\n // Parse numeric keys\n explorerKeys.forEach(k => {\n let v = parseFloat(val(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n // Custom calc for greenness (example)\n const hue = item.object_MeanHue || 0;\n const dist = Math.abs(hue - 80);\n item.custom_greenness = Math.max(0, 100 - dist);\n\n data.push(item);\n}\n\nmsg.payload = {\n meta,\n keys: explorerKeys,\n data\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5110,7 +5110,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -5158,7 +5158,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "GALLERY", - "func": "// GALLERY Function Node\n// TSV → { data: [], meta: { resolution: ... }, keys: ... }\n\n// 1. Handle Clear Signals\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 2. Validate Payload\nif (!msg.payload) return null;\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 3. Parse Headers\nconst headers = lines[0].split('\\t');\nconst get = (row, key) => {\n const idx = headers.indexOf(key);\n return idx >= 0 ? row[idx] : null;\n};\n\n// 4. Extract Meta (Resolution is key here)\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n const L = lines[i].trim();\n if (!L.startsWith('[') && L !== '') {\n firstRow = lines[i].split('\\t');\n break;\n }\n}\n\nconst meta = {\n sample_id: 'N/A',\n project: 'N/A',\n acq_id: 'N/A',\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = get(firstRow, 'sample_id') || \"\";\n meta.project = get(firstRow, 'sample_project') || \"\";\n meta.acq_id = get(firstRow, 'acq_id') || \"\";\n\n // CRITICAL: Extract microns per pixel\n const px = parseFloat(get(firstRow, 'process_pixel'));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 5. Select Keys to Display\nconst usefulKeys = [\n \"object_area\", \"object_width\", \"object_height\",\n \"object_equivalent_diameter\", \"object_major\", \"object_minor\",\n \"object_MeanHue\", \"object_elongation\", \"object_blur_laplacian\"\n];\n\nconst data = [];\n\n// 6. Process Rows\nfor (let i = 1; i < lines.length; i++) {\n const rowLine = lines[i].trim();\n if (rowLine.startsWith('[') || rowLine === '') continue;\n\n const row = rowLine.split('\\t');\n if (row.length !== headers.length) continue;\n\n const item = {\n id: get(row, 'object_id'),\n // Construct Image URL\n url: `/ps/data/browse/api/preview/big/objects/${(get(row, 'object_date')||'').replace(/ /g,'_')}/${(get(row, 'sample_id')||'').replace(/ /g,'_')}/${(get(row, 'acq_id')||'').replace(/ /g,'_')}/${get(row, 'img_file_name')}`\n };\n\n // Parse numeric values\n usefulKeys.forEach(k => {\n let v = parseFloat(get(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n data.push(item);\n}\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta\n};\n\nreturn msg;", + "func": "// GALLERY Function Node\n// TSV \u2192 { data: [], meta: { resolution: ... }, keys: ... }\n\n// 1. Handle Clear Signals\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 2. Validate Payload\nif (!msg.payload) return null;\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 3. Parse Headers\nconst headers = lines[0].split('\\t');\nconst get = (row, key) => {\n const idx = headers.indexOf(key);\n return idx >= 0 ? row[idx] : null;\n};\n\n// 4. Extract Meta (Resolution is key here)\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n const L = lines[i].trim();\n if (!L.startsWith('[') && L !== '') {\n firstRow = lines[i].split('\\t');\n break;\n }\n}\n\nconst meta = {\n sample_id: 'N/A',\n project: 'N/A',\n acq_id: 'N/A',\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = get(firstRow, 'sample_id') || \"\";\n meta.project = get(firstRow, 'sample_project') || \"\";\n meta.acq_id = get(firstRow, 'acq_id') || \"\";\n\n // CRITICAL: Extract microns per pixel\n const px = parseFloat(get(firstRow, 'process_pixel'));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 5. Select Keys to Display\nconst usefulKeys = [\n \"object_area\", \"object_width\", \"object_height\",\n \"object_equivalent_diameter\", \"object_major\", \"object_minor\",\n \"object_MeanHue\", \"object_elongation\", \"object_blur_laplacian\"\n];\n\nconst data = [];\n\n// 6. Process Rows\nfor (let i = 1; i < lines.length; i++) {\n const rowLine = lines[i].trim();\n if (rowLine.startsWith('[') || rowLine === '') continue;\n\n const row = rowLine.split('\\t');\n if (row.length !== headers.length) continue;\n\n const item = {\n id: get(row, 'object_id'),\n // Construct Image URL\n url: `/ps/data/browse/api/preview/big/objects/${(get(row, 'object_date')||'').replace(/ /g,'_')}/${(get(row, 'sample_id')||'').replace(/ /g,'_')}/${(get(row, 'acq_id')||'').replace(/ /g,'_')}/${get(row, 'img_file_name')}`\n };\n\n // Parse numeric values\n usefulKeys.forEach(k => {\n let v = parseFloat(get(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n data.push(item);\n}\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5339,7 +5339,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "Sample Identity Metadata Only", - "func": "// FUNCTION: Extract all data and metadata\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\n\n// Fonction utilitaire pour mapper les colonnes\nconst rowToObject = (rowCells) => {\n const obj = {};\n headers.forEach((header, index) => {\n obj[header] = rowCells[index] || \"\";\n });\n return obj;\n};\n\nlet allRows = [];\nlet firstDataRow = null;\n\n// --- 1. Parser toutes les lignes ---\nfor (let i = 1; i < lines.length; i++) {\n const cells = lines[i].split(\"\\t\");\n const rowObj = rowToObject(cells);\n\n allRows.push(rowObj);\n\n // Identifier la première vraie ligne de données pour les métadonnées globales\n if (!firstDataRow && !lines[i].trim().startsWith(\"[\")) {\n firstDataRow = rowObj;\n }\n}\n\n// --- 2. Extraire les métadonnées (basées sur la première ligne valide) ---\nconst meta = {\n sample_id: firstDataRow ? firstDataRow[\"sample_id\"] : \"\",\n project: firstDataRow ? firstDataRow[\"sample_project\"] : \"\",\n acq_id: firstDataRow ? firstDataRow[\"acq_id\"] : \"\"\n};\n\n// --- 3. Output : Métadonnées + l'intégralité des données ---\nmsg.payload = {\n meta: meta,\n data: allRows // Contient maintenant tous les enregistrements\n};\n\nreturn msg;", + "func": "// FUNCTION: Extract all data and metadata\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\n\n// Fonction utilitaire pour mapper les colonnes\nconst rowToObject = (rowCells) => {\n const obj = {};\n headers.forEach((header, index) => {\n obj[header] = rowCells[index] || \"\";\n });\n return obj;\n};\n\nlet allRows = [];\nlet firstDataRow = null;\n\n// --- 1. Parser toutes les lignes ---\nfor (let i = 1; i < lines.length; i++) {\n const cells = lines[i].split(\"\\t\");\n const rowObj = rowToObject(cells);\n\n allRows.push(rowObj);\n\n // Identifier la premi\u00e8re vraie ligne de donn\u00e9es pour les m\u00e9tadonn\u00e9es globales\n if (!firstDataRow && !lines[i].trim().startsWith(\"[\")) {\n firstDataRow = rowObj;\n }\n}\n\n// --- 2. Extraire les m\u00e9tadonn\u00e9es (bas\u00e9es sur la premi\u00e8re ligne valide) ---\nconst meta = {\n sample_id: firstDataRow ? firstDataRow[\"sample_id\"] : \"\",\n project: firstDataRow ? firstDataRow[\"sample_project\"] : \"\",\n acq_id: firstDataRow ? firstDataRow[\"acq_id\"] : \"\"\n};\n\n// --- 3. Output : M\u00e9tadonn\u00e9es + l'int\u00e9gralit\u00e9 des donn\u00e9es ---\nmsg.payload = {\n meta: meta,\n data: allRows // Contient maintenant tous les enregistrements\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5423,7 +5423,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n", + "format": "\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -5454,7 +5454,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "Insert export column", - "func": "msg.payload = msg.payload.map(item => {\n // extraction de l'acquisition_id\n const acq = item.acquisition_id; // ex: \"A_2\"\n\n // création du chemin export\n item.export = `/ps/data/browse/api/raw/export/ecotaxa/ecotaxa_${acq}.zip`;\n\n return item;\n});\nreturn msg;\n", + "func": "msg.payload = msg.payload.map(item => {\n // extraction de l'acquisition_id\n const acq = item.acquisition_id; // ex: \"A_2\"\n\n // cr\u00e9ation du chemin export\n item.export = `/ps/data/browse/api/raw/export/ecotaxa/ecotaxa_${acq}.zip`;\n\n return item;\n});\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5474,7 +5474,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "Streaming TSV reader", - "func": "// Streaming TSV reader — extracts only needed columns, never loads full file\nconst filePath = msg.payload.path || msg.filename;\nif (!filePath) return null;\n\nconst NEEDED = [\n 'object_x','object_y','object_equivalent_diameter','object_area',\n 'object_MeanSaturation','object_MeanValue','object_width','object_height',\n 'object_MeanHue','object_circ.','object_perim.','object_StdValue',\n 'object_solidity','object_elongation','object_major','object_minor',\n 'object_eccentricity','object_id','object_date','sample_id',\n 'sample_project','acq_id','img_file_name','process_pixel'\n];\n\nreturn new Promise((resolve) => {\n const columns = {};\n NEEDED.forEach(k => columns[k] = []);\n const colIdx = {};\n const meta = {};\n let totalRows = 0;\n let lineNum = 0;\n let remainder = '';\n\n const stream = fs.createReadStream(filePath, { encoding: 'utf8' });\n\n stream.on('data', chunk => {\n remainder += chunk;\n const lines = remainder.split('\\n');\n remainder = lines.pop();\n\n for (const line of lines) {\n if (!line.trim()) continue;\n lineNum++;\n\n if (lineNum === 1) {\n const headers = line.split('\\t');\n headers.forEach((h, i) => { if (NEEDED.includes(h)) colIdx[h] = i; });\n continue;\n }\n if (line.trim().startsWith('[')) continue;\n\n const fields = line.split('\\t');\n totalRows++;\n\n if (totalRows === 1) {\n meta.sample_id = fields[colIdx['sample_id']] || '';\n meta.sample_project = fields[colIdx['sample_project']] || '';\n meta.acq_id = fields[colIdx['acq_id']] || '';\n meta.process_pixel = fields[colIdx['process_pixel']] || '';\n }\n\n for (const key of NEEDED) {\n if (colIdx[key] !== undefined) {\n columns[key].push(fields[colIdx[key]]);\n }\n }\n }\n });\n\n stream.on('end', () => {\n if (remainder.trim() && !remainder.trim().startsWith('[') && lineNum > 0) {\n const fields = remainder.split('\\t');\n totalRows++;\n for (const key of NEEDED) {\n if (colIdx[key] !== undefined) {\n columns[key].push(fields[colIdx[key]]);\n }\n }\n }\n\n const sid = meta.sample_id || '';\n const proj = meta.sample_project || '';\n meta.display_sample_id = sid.split(proj + '_')[1] || sid;\n meta.display_acq_id = (meta.acq_id || '').split(meta.display_sample_id + '_')[1] || meta.acq_id;\n\n msg.payload = { columns, meta, totalRows };\n resolve(msg);\n });\n\n stream.on('error', err => {\n node.error('TSV stream error: ' + err.message, msg);\n resolve(null);\n });\n});", + "func": "// Streaming TSV reader \u2014 extracts only needed columns, never loads full file\nconst filePath = msg.payload.path || msg.filename;\nif (!filePath) return null;\n\nconst NEEDED = [\n 'object_x','object_y','object_equivalent_diameter','object_area',\n 'object_MeanSaturation','object_MeanValue','object_width','object_height',\n 'object_MeanHue','object_circ.','object_perim.','object_StdValue',\n 'object_solidity','object_elongation','object_major','object_minor',\n 'object_eccentricity','object_blur_laplacian','object_id','object_date','sample_id',\n 'sample_project','acq_id','img_file_name','process_pixel'\n];\n\nreturn new Promise((resolve) => {\n const columns = {};\n NEEDED.forEach(k => columns[k] = []);\n const colIdx = {};\n const meta = {};\n let totalRows = 0;\n let lineNum = 0;\n let remainder = '';\n\n const stream = fs.createReadStream(filePath, { encoding: 'utf8' });\n\n stream.on('data', chunk => {\n remainder += chunk;\n const lines = remainder.split('\\n');\n remainder = lines.pop();\n\n for (const line of lines) {\n if (!line.trim()) continue;\n lineNum++;\n\n if (lineNum === 1) {\n const headers = line.split('\\t');\n headers.forEach((h, i) => { if (NEEDED.includes(h)) colIdx[h] = i; });\n continue;\n }\n if (line.trim().startsWith('[')) continue;\n\n const fields = line.split('\\t');\n totalRows++;\n\n if (totalRows === 1) {\n meta.sample_id = fields[colIdx['sample_id']] || '';\n meta.sample_project = fields[colIdx['sample_project']] || '';\n meta.acq_id = fields[colIdx['acq_id']] || '';\n meta.process_pixel = fields[colIdx['process_pixel']] || '';\n }\n\n for (const key of NEEDED) {\n if (colIdx[key] !== undefined) {\n columns[key].push(fields[colIdx[key]]);\n }\n }\n }\n });\n\n stream.on('end', () => {\n if (remainder.trim() && !remainder.trim().startsWith('[') && lineNum > 0) {\n const fields = remainder.split('\\t');\n totalRows++;\n for (const key of NEEDED) {\n if (colIdx[key] !== undefined) {\n columns[key].push(fields[colIdx[key]]);\n }\n }\n }\n\n const sid = meta.sample_id || '';\n const proj = meta.sample_project || '';\n meta.display_sample_id = sid.split(proj + '_')[1] || sid;\n meta.display_acq_id = (meta.acq_id || '').split(meta.display_sample_id + '_')[1] || meta.acq_id;\n\n msg.payload = { columns, meta, totalRows };\n resolve(msg);\n });\n\n stream.on('error', err => {\n node.error('TSV stream error: ' + err.message, msg);\n resolve(null);\n });\n});", "outputs": 1, "timeout": 30, "noerr": 0, @@ -5543,7 +5543,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n\n\n", + "format": "\n\n\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -5611,7 +5611,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "HEATMAP (object_x, object_y)", - "func": "// Heatmap — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_x'];\nconst yCol = columns['object_y'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { meta, data, totalRows };\nreturn msg;", + "func": "// Heatmap \u2014 columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_x'];\nconst yCol = columns['object_y'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { x: parseFloat(xCol[i]), y: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { meta, data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5631,7 +5631,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "ESD Histogram (object_equivalent_diameter)", - "func": "// ESD Histogram — columnar input, all values for accurate histogram\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_equivalent_diameter'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { d: parseFloat(raw[i]) };\n}\n\nmsg.payload = { meta, data, totalRows };\nreturn msg;", + "func": "// ESD Histogram \u2014 columnar input, all values for accurate histogram\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_equivalent_diameter'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { d: parseFloat(raw[i]) };\n}\n\nmsg.payload = { meta, data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5651,7 +5651,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "TIMELINE (sequence index + area)", - "func": "// Timeline — columnar input, all values\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_area'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { seq: i, area: parseFloat(raw[i]) };\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", + "func": "// Timeline \u2014 columnar input, all values\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_area'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { seq: i, area: parseFloat(raw[i]) };\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5671,7 +5671,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "COLORSPACE (Saturation vs Value)", - "func": "// Colorspace — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_MeanSaturation'];\nconst yCol = columns['object_MeanValue'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", + "func": "// Colorspace \u2014 columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_MeanSaturation'];\nconst yCol = columns['object_MeanValue'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { s: parseFloat(xCol[i]), v: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5691,7 +5691,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "ASPECT (Width vs Height)", - "func": "// Aspect — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_width'];\nconst yCol = columns['object_height'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", + "func": "// Aspect \u2014 columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_width'];\nconst yCol = columns['object_height'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { w: parseFloat(xCol[i]), h: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5711,7 +5711,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "GREENNESS (custom index + circularity)", - "func": "// Greenness — columnar input with reservoir sampling + derived greenness\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst hueCol = columns['object_MeanHue'];\nconst circCol = columns['object_circ.'];\nif (!hueCol || !circCol) return null;\nconst n = hueCol.length;\n\nfunction makePoint(i) {\n const hue = parseFloat(hueCol[i]) || 0;\n const circ = parseFloat(circCol[i]) || 0;\n const g = Math.max(0, 100 - Math.abs(hue - 80));\n return { g, circ };\n}\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) data[i] = makePoint(i);\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) data[i] = makePoint(i);\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) data[j] = makePoint(i);\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", + "func": "// Greenness \u2014 columnar input with reservoir sampling + derived greenness\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst hueCol = columns['object_MeanHue'];\nconst circCol = columns['object_circ.'];\nif (!hueCol || !circCol) return null;\nconst n = hueCol.length;\n\nfunction makePoint(i) {\n const hue = parseFloat(hueCol[i]) || 0;\n const circ = parseFloat(circCol[i]) || 0;\n const g = Math.max(0, 100 - Math.abs(hue - 80));\n return { g, circ };\n}\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) data[i] = makePoint(i);\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) data[i] = makePoint(i);\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) data[j] = makePoint(i);\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5731,7 +5731,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "COMPLEXITY (Area vs Perimeter)", - "func": "// Complexity — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_area'];\nconst yCol = columns['object_perim.'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", + "func": "// Complexity \u2014 columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_area'];\nconst yCol = columns['object_perim.'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { area: parseFloat(xCol[i]), per: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5751,7 +5751,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "TEXTURE (Area vs StdValue)", - "func": "// Texture — columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_area'];\nconst yCol = columns['object_StdValue'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", + "func": "// Texture \u2014 columnar input with reservoir sampling\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_POINTS = 10000;\nconst xCol = columns['object_area'];\nconst yCol = columns['object_StdValue'];\nif (!xCol || !yCol) return null;\nconst n = xCol.length;\n\nlet data;\nif (n <= MAX_POINTS) {\n data = new Array(n);\n for (let i = 0; i < n; i++) {\n data[i] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n} else {\n data = new Array(MAX_POINTS);\n for (let i = 0; i < MAX_POINTS; i++) {\n data[i] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n for (let i = MAX_POINTS; i < n; i++) {\n const j = Math.floor(Math.random() * (i + 1));\n if (j < MAX_POINTS) {\n data[j] = { area: parseFloat(xCol[i]), std: parseFloat(yCol[i]) };\n }\n }\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5771,7 +5771,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "SOLIDITY (Histogram)", - "func": "// Solidity Histogram — columnar input, all values\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_solidity'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { sol: parseFloat(raw[i]) };\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", + "func": "// Solidity Histogram \u2014 columnar input, all values\nconst { columns, meta, totalRows } = msg.payload;\nconst raw = columns['object_solidity'];\nif (!raw) return null;\n\nconst data = new Array(raw.length);\nfor (let i = 0; i < raw.length; i++) {\n data[i] = { sol: parseFloat(raw[i]) };\n}\n\nmsg.payload = { data, totalRows };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5815,7 +5815,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "Sample Identity Metadata Only", - "func": "// Sample Identity — extract metadata from columnar input\nconst { meta } = msg.payload;\nmsg.payload = { meta: {\n sample_id: meta.sample_id || '',\n project: meta.sample_project || '',\n acq_id: meta.acq_id || ''\n}};\nreturn msg;", + "func": "// Sample Identity \u2014 extract metadata from columnar input\nconst { meta } = msg.payload;\nmsg.payload = { meta: {\n sample_id: meta.sample_id || '',\n project: meta.sample_project || '',\n acq_id: meta.acq_id || ''\n}};\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6027,7 +6027,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "EXPLORER", - "func": "// Explorer — columnar input, pre-sorted by area (largest first), capped at 5000\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_ROWS = 5000;\nconst idCol = columns['object_id'];\nif (!idCol) return null;\n\nconst explorerKeys = [\n \"object_area\",\"object_equivalent_diameter\",\"object_perim.\",\n \"object_major\",\"object_minor\",\"object_width\",\"object_height\",\n \"object_circ.\",\"object_elongation\",\"object_solidity\",\n \"object_eccentricity\",\"object_MeanHue\",\"object_MeanSaturation\",\n \"object_MeanValue\",\"object_StdValue\"\n];\n\nconst px = parseFloat(meta.process_pixel);\nconst resolution = (!isNaN(px) && px > 0) ? px : 1.0;\n\n// Build all rows first (lightweight: ~200 bytes each)\nconst allRows = [];\nfor (let i = 0; i < idCol.length; i++) {\n const item = {\n id: columns['object_id'][i],\n url: `/ps/node-red-v2/my-images/${(columns['object_date'][i]||'').replace(/ /g,'_')}/${(columns['sample_id'][i]||'').replace(/ /g,'_')}/${(columns['acq_id'][i]||'').replace(/ /g,'_')}/${columns['img_file_name'][i]}`,\n sequence_index: i\n };\n explorerKeys.forEach(k => {\n const v = parseFloat((columns[k] || [])[i]);\n item[k] = isNaN(v) ? 0 : v;\n });\n const hue = item.object_MeanHue || 0;\n item.custom_greenness = Math.max(0, 100 - Math.abs(hue - 80));\n allRows.push(item);\n}\n\n// Pre-sort by area descending (the default sort in the UI)\nallRows.sort((a, b) => b.object_area - a.object_area);\n\n// Cap to MAX_ROWS\nconst data = allRows.length > MAX_ROWS ? allRows.slice(0, MAX_ROWS) : allRows;\n\nmsg.payload = {\n meta: { sample_id: meta.sample_id, project: meta.sample_project, acq_id: meta.acq_id, resolution },\n keys: explorerKeys,\n data,\n totalRows,\n showing: data.length\n};\nreturn msg;", + "func": "// Explorer \u2014 columnar input, pre-sorted by area (largest first), capped at 5000\nconst { columns, meta, totalRows } = msg.payload;\nconst MAX_ROWS = 5000;\nconst idCol = columns['object_id'];\nif (!idCol) return null;\n\nconst explorerKeys = [\n \"object_area\",\"object_equivalent_diameter\",\"object_perim.\",\n \"object_major\",\"object_minor\",\"object_width\",\"object_height\",\n \"object_circ.\",\"object_elongation\",\"object_solidity\",\n \"object_eccentricity\",\"object_MeanHue\",\"object_MeanSaturation\",\n \"object_MeanValue\",\"object_StdValue\",\"object_blur_laplacian\"\n];\n\nconst px = parseFloat(meta.process_pixel);\nconst resolution = (!isNaN(px) && px > 0) ? px : 1.0;\n\n// Build all rows first (lightweight: ~200 bytes each)\nconst allRows = [];\nfor (let i = 0; i < idCol.length; i++) {\n const item = {\n id: columns['object_id'][i],\n url: `/ps/node-red-v2/my-images/${(columns['object_date'][i]||'').replace(/ /g,'_')}/${(columns['sample_id'][i]||'').replace(/ /g,'_')}/${(columns['acq_id'][i]||'').replace(/ /g,'_')}/${columns['img_file_name'][i]}`,\n sequence_index: i\n };\n explorerKeys.forEach(k => {\n const v = parseFloat((columns[k] || [])[i]);\n item[k] = isNaN(v) ? 0 : v;\n });\n const hue = item.object_MeanHue || 0;\n item.custom_greenness = Math.max(0, 100 - Math.abs(hue - 80));\n allRows.push(item);\n}\n\n// Pre-sort by area descending (the default sort in the UI)\nallRows.sort((a, b) => b.object_area - a.object_area);\n\n// Cap to MAX_ROWS\nconst data = allRows.length > MAX_ROWS ? allRows.slice(0, MAX_ROWS) : allRows;\n\nmsg.payload = {\n meta: { sample_id: meta.sample_id, project: meta.sample_project, acq_id: meta.acq_id, resolution },\n keys: explorerKeys,\n data,\n totalRows,\n showing: data.length\n};\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6054,7 +6054,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -6102,7 +6102,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "GALLERY", - "func": "// Gallery — columnar input, pre-sorted by area (largest first), capped at 5000\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst { columns, meta, totalRows } = msg.payload;\nif (!columns) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst MAX_ROWS = 5000;\nconst idCol = columns['object_id'];\nif (!idCol) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst usefulKeys = [\n \"object_area\",\"object_width\",\"object_height\",\n \"object_equivalent_diameter\",\"object_major\",\"object_minor\",\n \"object_MeanHue\",\"object_elongation\"\n];\n\nconst px = parseFloat(meta.process_pixel);\nconst resolution = (!isNaN(px) && px > 0) ? px : 1.0;\n\n// Build all rows\nconst allRows = [];\nfor (let i = 0; i < idCol.length; i++) {\n const item = {\n id: columns['object_id'][i],\n url: `/ps/node-red-v2/my-images/${(columns['object_date'][i]||'').replace(/ /g,'_')}/${(columns['sample_id'][i]||'').replace(/ /g,'_')}/${(columns['acq_id'][i]||'').replace(/ /g,'_')}/${columns['img_file_name'][i]}`\n };\n usefulKeys.forEach(k => {\n const v = parseFloat((columns[k] || [])[i]);\n item[k] = isNaN(v) ? 0 : v;\n });\n allRows.push(item);\n}\n\n// Pre-sort by area descending (default sort in UI)\nallRows.sort((a, b) => b.object_area - a.object_area);\n\n// Cap to MAX_ROWS\nconst data = allRows.length > MAX_ROWS ? allRows.slice(0, MAX_ROWS) : allRows;\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta: { sample_id: meta.sample_id, project: meta.sample_project, acq_id: meta.acq_id, resolution },\n totalRows,\n showing: data.length\n};\nreturn msg;", + "func": "// Gallery \u2014 columnar input, pre-sorted by area (largest first), capped at 5000\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst { columns, meta, totalRows } = msg.payload;\nif (!columns) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst MAX_ROWS = 5000;\nconst idCol = columns['object_id'];\nif (!idCol) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\nconst usefulKeys = [\n \"object_area\",\"object_width\",\"object_height\",\n \"object_equivalent_diameter\",\"object_major\",\"object_minor\",\n \"object_MeanHue\",\"object_elongation\",\"object_blur_laplacian\"\n];\n\nconst px = parseFloat(meta.process_pixel);\nconst resolution = (!isNaN(px) && px > 0) ? px : 1.0;\n\n// Build all rows\nconst allRows = [];\nfor (let i = 0; i < idCol.length; i++) {\n const item = {\n id: columns['object_id'][i],\n url: `/ps/node-red-v2/my-images/${(columns['object_date'][i]||'').replace(/ /g,'_')}/${(columns['sample_id'][i]||'').replace(/ /g,'_')}/${(columns['acq_id'][i]||'').replace(/ /g,'_')}/${columns['img_file_name'][i]}`\n };\n usefulKeys.forEach(k => {\n const v = parseFloat((columns[k] || [])[i]);\n item[k] = isNaN(v) ? 0 : v;\n });\n allRows.push(item);\n}\n\n// Pre-sort by area descending (default sort in UI)\nallRows.sort((a, b) => b.object_area - a.object_area);\n\n// Cap to MAX_ROWS\nconst data = allRows.length > MAX_ROWS ? allRows.slice(0, MAX_ROWS) : allRows;\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta: { sample_id: meta.sample_id, project: meta.sample_project, acq_id: meta.acq_id, resolution },\n totalRows,\n showing: data.length\n};\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6267,7 +6267,7 @@ "type": "function", "z": "a7825c4c81ad20a0", "name": "Sample Identity Metadata Only", - "func": "// FUNCTION: Extract all data and metadata\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\n\n// Fonction utilitaire pour mapper les colonnes\nconst rowToObject = (rowCells) => {\n const obj = {};\n headers.forEach((header, index) => {\n obj[header] = rowCells[index] || \"\";\n });\n return obj;\n};\n\nlet allRows = [];\nlet firstDataRow = null;\n\n// --- 1. Parser toutes les lignes ---\nfor (let i = 1; i < lines.length; i++) {\n const cells = lines[i].split(\"\\t\");\n const rowObj = rowToObject(cells);\n\n allRows.push(rowObj);\n\n // Identifier la première vraie ligne de données pour les métadonnées globales\n if (!firstDataRow && !lines[i].trim().startsWith(\"[\")) {\n firstDataRow = rowObj;\n }\n}\n\n// --- 2. Extraire les métadonnées (basées sur la première ligne valide) ---\nconst meta = {\n sample_id: firstDataRow ? firstDataRow[\"sample_id\"] : \"\",\n project: firstDataRow ? firstDataRow[\"sample_project\"] : \"\",\n acq_id: firstDataRow ? firstDataRow[\"acq_id\"] : \"\"\n};\n\n// --- 3. Output : Métadonnées + l'intégralité des données ---\nmsg.payload = {\n meta: meta,\n data: allRows // Contient maintenant tous les enregistrements\n};\n\nreturn msg;", + "func": "// FUNCTION: Extract all data and metadata\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\n\n// Fonction utilitaire pour mapper les colonnes\nconst rowToObject = (rowCells) => {\n const obj = {};\n headers.forEach((header, index) => {\n obj[header] = rowCells[index] || \"\";\n });\n return obj;\n};\n\nlet allRows = [];\nlet firstDataRow = null;\n\n// --- 1. Parser toutes les lignes ---\nfor (let i = 1; i < lines.length; i++) {\n const cells = lines[i].split(\"\\t\");\n const rowObj = rowToObject(cells);\n\n allRows.push(rowObj);\n\n // Identifier la premi\u00e8re vraie ligne de donn\u00e9es pour les m\u00e9tadonn\u00e9es globales\n if (!firstDataRow && !lines[i].trim().startsWith(\"[\")) {\n firstDataRow = rowObj;\n }\n}\n\n// --- 2. Extraire les m\u00e9tadonn\u00e9es (bas\u00e9es sur la premi\u00e8re ligne valide) ---\nconst meta = {\n sample_id: firstDataRow ? firstDataRow[\"sample_id\"] : \"\",\n project: firstDataRow ? firstDataRow[\"sample_project\"] : \"\",\n acq_id: firstDataRow ? firstDataRow[\"acq_id\"] : \"\"\n};\n\n// --- 3. Output : M\u00e9tadonn\u00e9es + l'int\u00e9gralit\u00e9 des donn\u00e9es ---\nmsg.payload = {\n meta: meta,\n data: allRows // Contient maintenant tous les enregistrements\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6450,7 +6450,7 @@ "type": "function", "z": "6fac9fa6a894b293", "name": "set light settings", - "func": "// On vérifie si le message contient les propriétés attendues\n// avant de mettre à jour les variables globales.\n\nif (msg.payload) {\n\n // Si led_status est présent dans le payload, on stocke\n if (msg.payload.led_status !== undefined) {\n global.set(\"led_status\", msg.payload.led_status);\n }\n\n // Si calibration_led_intensity est présent dans le payload, on stocke\n if (msg.payload.calibration_led_intensity !== undefined) {\n global.set(\"calibration_led_intensity\", msg.payload.calibration_led_intensity);\n }\n}\n\nreturn msg;", + "func": "// On v\u00e9rifie si le message contient les propri\u00e9t\u00e9s attendues\n// avant de mettre \u00e0 jour les variables globales.\n\nif (msg.payload) {\n\n // Si led_status est pr\u00e9sent dans le payload, on stocke\n if (msg.payload.led_status !== undefined) {\n global.set(\"led_status\", msg.payload.led_status);\n }\n\n // Si calibration_led_intensity est pr\u00e9sent dans le payload, on stocke\n if (msg.payload.calibration_led_intensity !== undefined) {\n global.set(\"calibration_led_intensity\", msg.payload.calibration_led_intensity);\n }\n}\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -6758,7 +6758,7 @@ "width": "0", "height": "0", "head": "", - "format": "\n\n\n\n\n", + "format": "\n\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -6863,7 +6863,7 @@ "width": "0", "height": "0", "head": "", - "format": "\n\n\n\n\n\n", + "format": "\n\n\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -7092,7 +7092,7 @@ "width": "0", "height": "0", "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -7706,7 +7706,7 @@ "type": "comment", "z": "e6d820ba0f4f184e", "g": "039d9046b10d6840", - "name": "system ⚠️", + "name": "system \u26a0\ufe0f", "info": "", "x": 1200, "y": 80,