diff --git a/flows.json b/flows.json index 4c696ba..594a2f7 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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -4333,7 +4333,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n", + "format": "\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -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, @@ -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_blur_laplacian','object_id','object_date','sample_id',\n 'sample_project','acq_id','img_file_name','process_pixel','process_pixel_applied'\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 meta.process_pixel_applied = fields[colIdx['process_pixel_applied']] || '';\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 // Backward-compat: convert legacy pixel-unit data to µm\n const px = parseFloat(meta.process_pixel);\n if (px > 0 && !meta.process_pixel_applied) {\n const px2 = px * px;\n const LINEAR = [\n 'object_equivalent_diameter','object_width','object_height',\n 'object_major','object_minor','object_perim.'\n ];\n const AREA = ['object_area'];\n LINEAR.forEach(k => {\n if (columns[k]) {\n for (let i = 0; i < columns[k].length; i++) {\n columns[k][i] = String(parseFloat(columns[k][i]) * px);\n }\n }\n });\n AREA.forEach(k => {\n if (columns[k]) {\n for (let i = 0; i < columns[k].length; i++) {\n columns[k][i] = String(parseFloat(columns[k][i]) * px2);\n }\n }\n });\n meta.process_pixel_applied = 'dashboard';\n }\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\",\"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, @@ -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\",\"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,