diff --git a/app/api/api.rb b/app/api/api.rb index 9cbb3df192..40f79ce3bc 100644 --- a/app/api/api.rb +++ b/app/api/api.rb @@ -191,6 +191,7 @@ def to_json_camel_case(val) mount Chemotion::DevicesAnalysisAPI mount Chemotion::GateAPI mount Chemotion::ElementAPI + mount Chemotion::ExplorerAPI mount Chemotion::ChemSpectraAPI mount Chemotion::InstrumentAPI mount Chemotion::MessageAPI diff --git a/app/api/chemotion/explorer_api.rb b/app/api/chemotion/explorer_api.rb new file mode 100644 index 0000000000..ee222d1a9f --- /dev/null +++ b/app/api/chemotion/explorer_api.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Chemotion + class ExplorerAPI < Grape::API + + resource :explorer do + desc 'Fetch samples, reactions, and molecules belonging to a collection' + + params do + requires :collection_id, type: Integer, desc: 'ID of the collection to explore' + end + + after_validation do + @collection = current_user + .collections + .where(is_shared: false) + .find_by(id: params[:collection_id]) + + error!({ error: 'Collection not found' }, 404) unless @collection + end + + get do + samples = @collection + .samples + .select(:id, :ancestry, :molecule_id, :name, :short_label, :sample_svg_file) + + # reactions = @collection + # .reactions + # .select(:id, :name, :short_label) + reactions = @collection.reactions.includes(:reactants, :products).map do |r| + { + id: r.id, + name: r.name, + short_label: r.short_label, + starting_material_ids: r.starting_materials.pluck(:id), + reactant_ids: r.reactants.pluck(:id), + product_ids: r.products.pluck(:id), + created_at: r.created_at, + updated_at: r.updated_at, + reaction_svg_file: r.reaction_svg_file + } + end + + + molecule_ids = samples.pluck(:molecule_id).compact.uniq + molecules = Molecule + .where(id: molecule_ids) + .select(:id, :cano_smiles, :inchikey, :iupac_name) + + { + samples: samples.as_json, + reactions: reactions.as_json, + molecules: molecules.as_json + } + end + end + end +end diff --git a/app/javascript/src/apps/mydb/elements/details/ElementDetails.js b/app/javascript/src/apps/mydb/elements/details/ElementDetails.js index 6c39913ef9..0572d15539 100644 --- a/app/javascript/src/apps/mydb/elements/details/ElementDetails.js +++ b/app/javascript/src/apps/mydb/elements/details/ElementDetails.js @@ -24,6 +24,7 @@ import VesselDetails from 'src/apps/mydb/elements/details/vessels/VesselDetails' import VesselTemplateDetails from 'src/apps/mydb/elements/details/vessels/VesselTemplateDetails'; import VesselTemplateCreate from 'src/apps/mydb/elements/details/vessels/VesselTemplateCreate'; import SequenceBasedMacromoleculeSampleDetails from 'src/apps/mydb/elements/details/sequenceBasedMacromoleculeSamples/SequenceBasedMacromoleculeSampleDetails'; +import ExplorerContainer from 'src/apps/mydb/elements/details/explorer/ExplorerContainer'; const tabInfoHash = { metadata: { @@ -46,6 +47,16 @@ const tabInfoHash = { ) }, + explorer: { + title: 'Explorer', + iconEl: ( + + + {/*    + */} + + ) + }, prediction: { title: 'Synthesis Prediction', iconEl: ( @@ -154,6 +165,8 @@ export default class ElementDetails extends Component { return ; case 'report': return ; + case 'explorer': + return ; case 'prediction': //return ; console.warn('Attempting to show outdated PredictionContainer') diff --git a/app/javascript/src/apps/mydb/elements/details/explorer/ExplorerComponent.js b/app/javascript/src/apps/mydb/elements/details/explorer/ExplorerComponent.js new file mode 100644 index 0000000000..a8775f8a6e --- /dev/null +++ b/app/javascript/src/apps/mydb/elements/details/explorer/ExplorerComponent.js @@ -0,0 +1,557 @@ +import React, { useState, useMemo, useRef } from 'react'; +import ReactFlow, { + MiniMap, + Controls, + Background, + useNodesState, + useEdgesState, +} from 'reactflow'; +import { Button } from 'react-bootstrap'; +import DetailActions from 'src/stores/alt/actions/DetailActions'; + +const clickToClose = (explorer) => { + DetailActions.close(explorer, true); +}; + +export const CloseBtn = ({ explorer }) => ( + +); + +export default function ExplorerComponent({ nodes, edges }) { + const [rfNodes, , onNodesChange] = useNodesState(nodes); + const [rfEdges, , onEdgesChange] = useEdgesState(edges); + + const [activeFilters] = useState([ + 'molecule', + 'sample', + 'splitsample', + 'reaction', + ]); + + const [searchTerm, setSearchTerm] = useState(''); + const [focusSearchOnly, setFocusSearchOnly] = useState(true); + + const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const matchesQuery = (value, query) => { + const v = (value || '').toLowerCase(); + const q = (query || '').trim().toLowerCase(); + if (!q) return false; + + if (v === q) return true; + + const looksLikeLabel = /[\d._\-/]/.test(q); + + if (looksLikeLabel) { + // strict token-boundary + const boundaryPat = new RegExp(`(^|[\\s._\\-/])${escapeRegExp(q)}($|[\\s._\\-/])`, 'i'); + if (boundaryPat.test(v)) return true; + + // suffix-after-separator: r5 -> pst-r5 + const suffixPat = new RegExp(`[\\s._\\-/]${escapeRegExp(q)}$`, 'i'); + return suffixPat.test(v); + } + + // plain text contains + return v.includes(q); + }; + + const nodeMap = useMemo(() => { + const map = {}; + rfNodes.forEach((n) => { + map[n.id] = n; + }); + return map; + }, [rfNodes]); + + const filteredNodes = useMemo( + () => rfNodes.filter((n) => activeFilters.includes(n.type)), + [rfNodes, activeFilters] + ); + + const filteredEdges = useMemo(() => { + return rfEdges.filter((e) => { + const s = nodeMap[e.source]; + const t = nodeMap[e.target]; + return s && t && + activeFilters.includes(s.type) && + activeFilters.includes(t.type); + }); + }, [rfEdges, nodeMap, activeFilters]); + + const reactionSearchResult = useMemo(() => { + const q = searchTerm.trim().toLowerCase(); + if (!q) { + return { + hasSearch: false, + matchedReactionIds: new Set(), + highlightedNodeIds: new Set(), + }; + } + + const reactionNodes = filteredNodes.filter((n) => n.type === 'reaction'); + const sampleNodes = filteredNodes.filter((n) => n.type === 'sample'); + + const matchedReactionIds = new Set( + reactionNodes + .filter((n) => { + const shortLabel = (n.data?.reactionShortLabel || '').toLowerCase(); + const name = (n.data?.reactionName || '').toLowerCase(); + const label = (n.data?.label || '').toLowerCase(); + + return ( + matchesQuery(shortLabel, q) || + matchesQuery(name, q) || + matchesQuery(label, q) + ); + }) + .map((n) => n.id) + ); + + const matchedSampleIds = new Set( + sampleNodes + .filter((n) => { + const label = (n.data?.label || '').toLowerCase(); + const sampleName = (n.data?.sampleName || '').toLowerCase(); + + return ( + matchesQuery(label, q) || + matchesQuery(sampleName, q) + ); + }) + .map((n) => n.id) + ); + + const highlightedNodeIds = new Set(); + + const reactionToSamples = {}; + const sampleToReactions = {}; + const splitChildren = {}; + const splitParents = {}; + + const reactionNodeById = {}; + reactionNodes.forEach((n) => { + reactionNodeById[n.id] = n; + }); + + filteredEdges.forEach((e) => { + const srcIsSample = e.source.startsWith('sample-'); + const srcIsReaction = e.source.startsWith('reaction-'); + const tgtIsSample = e.target.startsWith('sample-'); + const tgtIsReaction = e.target.startsWith('reaction-'); + + if (srcIsSample && tgtIsReaction) { + if (!reactionToSamples[e.target]) reactionToSamples[e.target] = new Set(); + reactionToSamples[e.target].add(e.source); + + if (!sampleToReactions[e.source]) sampleToReactions[e.source] = new Set(); + sampleToReactions[e.source].add(e.target); + } + + if (srcIsReaction && tgtIsSample) { + if (!reactionToSamples[e.source]) reactionToSamples[e.source] = new Set(); + reactionToSamples[e.source].add(e.target); + + if (!sampleToReactions[e.target]) sampleToReactions[e.target] = new Set(); + sampleToReactions[e.target].add(e.source); + } + + if (srcIsSample && tgtIsSample) { + if (!splitChildren[e.source]) splitChildren[e.source] = new Set(); + splitChildren[e.source].add(e.target); + + if (!splitParents[e.target]) splitParents[e.target] = new Set(); + splitParents[e.target].add(e.source); + } + }); + + Object.keys(reactionNodeById).forEach((rid) => { + const reagentNodeIds = reactionNodeById[rid]?.data?.reactionReagentNodeIds || []; + reagentNodeIds.forEach((sid) => { + if (!sampleToReactions[sid]) sampleToReactions[sid] = new Set(); + sampleToReactions[sid].add(rid); + }); + }); + + const expandedSampleIds = new Set(); + const splitQueueSeed = [...matchedSampleIds]; + + while (splitQueueSeed.length > 0) { + const sid = splitQueueSeed.shift(); + if (expandedSampleIds.has(sid)) continue; + expandedSampleIds.add(sid); + + const children = splitChildren[sid] || new Set(); + const parents = splitParents[sid] || new Set(); + + children.forEach((childSid) => { + if (!expandedSampleIds.has(childSid)) splitQueueSeed.push(childSid); + }); + + parents.forEach((parentSid) => { + if (!expandedSampleIds.has(parentSid)) splitQueueSeed.push(parentSid); + }); + } + + const reactionQueue = [...matchedReactionIds]; + + expandedSampleIds.forEach((sid) => { + const rset = sampleToReactions[sid] || new Set(); + rset.forEach((rid) => reactionQueue.push(rid)); + highlightedNodeIds.add(sid); + }); + + const visitedReactions = new Set(); + const visitedSplitSamples = new Set(); + + while (reactionQueue.length > 0) { + const rid = reactionQueue.shift(); + if (visitedReactions.has(rid)) continue; + + visitedReactions.add(rid); + highlightedNodeIds.add(rid); + + const reagentNodeIds = reactionNodeById[rid]?.data?.reactionReagentNodeIds || []; + reagentNodeIds.forEach((sid) => highlightedNodeIds.add(sid)); + + const blockSamples = reactionToSamples[rid] || new Set(); + blockSamples.forEach((sid) => highlightedNodeIds.add(sid)); + + const splitQueue = [...blockSamples]; + while (splitQueue.length > 0) { + const sid = splitQueue.shift(); + if (visitedSplitSamples.has(sid)) continue; + visitedSplitSamples.add(sid); + + const children = splitChildren[sid] || new Set(); + children.forEach((childSid) => { + highlightedNodeIds.add(childSid); + splitQueue.push(childSid); + + const childReactions = sampleToReactions[childSid] || new Set(); + childReactions.forEach((crid) => { + if (!visitedReactions.has(crid)) reactionQueue.push(crid); + }); + }); + } + } + + return { + hasSearch: true, + matchedReactionIds, + highlightedNodeIds, + }; + }, [searchTerm, filteredNodes, filteredEdges]); + + const displayNodes = useMemo(() => { + if (!reactionSearchResult.hasSearch) return filteredNodes; + const { matchedReactionIds, highlightedNodeIds } = reactionSearchResult; + + if (focusSearchOnly) { + return filteredNodes + .filter((n) => highlightedNodeIds.has(n.id)) + .map((n) => { + const isMatchedReaction = matchedReactionIds.has(n.id); + + if (isMatchedReaction) { + return { + ...n, + style: { ...(n.style || {}), boxShadow: '0 0 0 3px #2563eb' }, + }; + } + + if (n.type === 'reaction') { + return { + ...n, + style: { ...(n.style || {}), boxShadow: '0 0 0 2px #60a5fa' }, + }; + } + + return { + ...n, + style: { ...(n.style || {}), boxShadow: '0 0 0 2px #22c55e' }, + }; + }); + } + + return filteredNodes.map((n) => { + const isHighlighted = highlightedNodeIds.has(n.id); + const isMatchedReaction = matchedReactionIds.has(n.id); + + if (!isHighlighted) { + return { ...n, style: { ...(n.style || {}), opacity: 0.2 } }; + } + + if (isMatchedReaction) { + return { + ...n, + style: { ...(n.style || {}), opacity: 1, boxShadow: '0 0 0 3px #2563eb' }, + }; + } + + if (n.type === 'reaction') { + return { + ...n, + style: { ...(n.style || {}), opacity: 1, boxShadow: '0 0 0 2px #60a5fa' }, + }; + } + + return { + ...n, + style: { ...(n.style || {}), opacity: 1, boxShadow: '0 0 0 2px #22c55e' }, + }; + }); + }, [filteredNodes, reactionSearchResult, focusSearchOnly]); + + const displayEdges = useMemo(() => { + if (!reactionSearchResult.hasSearch) return filteredEdges; + const { highlightedNodeIds } = reactionSearchResult; + + if (focusSearchOnly) { + return filteredEdges + .filter((e) => highlightedNodeIds.has(e.source) && highlightedNodeIds.has(e.target)) + .map((e) => ({ + ...e, + style: { + ...(e.style || {}), + opacity: 1, + strokeWidth: (e.style?.strokeWidth || 1.5) + 0.5, + }, + labelStyle: { + ...(e.labelStyle || {}), + opacity: 1, + fontWeight: 600, + }, + })); + } + + return filteredEdges.map((e) => { + const isHighlighted = + highlightedNodeIds.has(e.source) && highlightedNodeIds.has(e.target); + + if (!isHighlighted) { + return { + ...e, + style: { ...(e.style || {}), opacity: 0.12 }, + labelStyle: { ...(e.labelStyle || {}), opacity: 0.12 }, + }; + } + + return { + ...e, + style: { + ...(e.style || {}), + opacity: 1, + strokeWidth: (e.style?.strokeWidth || 1.5) + 0.5, + }, + labelStyle: { + ...(e.labelStyle || {}), + opacity: 1, + fontWeight: 600, + }, + }; + }); + }, [filteredEdges, reactionSearchResult, focusSearchOnly]); + + const wrapperRef = useRef(null); + const [hover, setHover] = useState(null); + + const getHoverBoxSize = (kind) => { + const reactionWidth = Math.min(900, window.innerWidth * 0.75); + return kind === 'reaction' + ? { w: reactionWidth, h: Math.min(window.innerHeight * 0.65 + 80, 560) } + : { w: 220, h: 260 }; + }; + + const clampHoverPos = (rawX, rawY, kind) => { + if (!wrapperRef.current) return { x: rawX, y: rawY }; + const rect = wrapperRef.current.getBoundingClientRect(); + const { w, h } = getHoverBoxSize(kind); + + const maxX = Math.max(8, rect.width - w - 8); + const maxY = Math.max(8, rect.height - h - 8); + + return { + x: Math.min(Math.max(8, rawX), maxX), + y: Math.min(Math.max(8, rawY), maxY), + }; + }; + + const updateHoverPos = (event) => { + if (!hover || !wrapperRef.current) return; + const rect = wrapperRef.current.getBoundingClientRect(); + const rawX = event.clientX - rect.left + 12; + const rawY = event.clientY - rect.top + 12; + const pos = clampHoverPos(rawX, rawY, hover.kind); + + setHover((prev) => prev && ({ ...prev, x: pos.x, y: pos.y })); + }; + + const formatPerformedAt = (isoDate) => { + if (!isoDate) return 'Date not available'; + const date = new Date(isoDate); + if (Number.isNaN(date.getTime())) return 'Date not available'; + return date.toLocaleString(); + }; + + return ( +
+
+ setSearchTerm(e.target.value)} + style={{ + width: '100%', + border: '1px solid #d1d5db', + borderRadius: 4, + padding: '6px 8px', + fontSize: 12, + }} + /> + +
+ + {hover && (hover.src || hover.text) && ( +
+ {(hover.text || hover.meta) && ( +
+
{hover.text}
+ {hover.meta && ( +
+ {hover.meta} +
+ )} +
+ )} + + {hover.src && ( + {hover.text + )} +
+ )} + + { + if (!wrapperRef.current) return; + const rect = wrapperRef.current.getBoundingClientRect(); + + const isSample = node?.type === 'sample'; + const isReaction = node?.type === 'reaction'; + + let src = null; + let text = null; + let meta = null; + let kind = null; + + if (isSample) { + src = node?.data?.image; + text = node?.data?.label; + kind = 'sample'; + } + + if (isReaction) { + src = node?.data?.reactionImage; + const shortLabel = node?.data?.reactionShortLabel || ''; + const name = node?.data?.reactionName || ''; + const performedAt = node?.data?.reactionPerformedAt; + const performedText = formatPerformedAt(performedAt); + + text = shortLabel && name ? `${shortLabel}: ${name}` : (shortLabel || name || 'Reaction'); + meta = `Performed: ${performedText}`; + kind = 'reaction'; + } + + if (!src && !text) return; + + const rawX = event.clientX - rect.left + 12; + const rawY = event.clientY - rect.top + 12; + const pos = clampHoverPos(rawX, rawY, kind); + + setHover({ + src, + text, + meta, + kind, + x: pos.x, + y: pos.y, + }); + }} + onNodeMouseLeave={() => setHover(null)} + > + + + + +
+ ); +} + diff --git a/app/javascript/src/apps/mydb/elements/details/explorer/ExplorerContainer.js b/app/javascript/src/apps/mydb/elements/details/explorer/ExplorerContainer.js new file mode 100644 index 0000000000..efe81a0529 --- /dev/null +++ b/app/javascript/src/apps/mydb/elements/details/explorer/ExplorerContainer.js @@ -0,0 +1,382 @@ +import React, { Component } from 'react'; +import ExplorerComponent from './ExplorerComponent'; +import ExplorerFetcher from 'src/fetchers/ExplorerFetcher'; +import DetailCard from 'src/apps/mydb/elements/details/DetailCard'; +import UIStore from 'src/stores/alt/stores/UIStore'; +import { CloseBtn } from './ExplorerComponent'; + +function positionNodesByReaction(samples, reactions) { + const nodes = []; + const edges = []; + + const V_BLOCK = 360; + const V_GAP = 120; + const H_GAP = 200; + const BRANCH_X = 320; + const MIN_ROW_GAP = 480; + + const sampleById = Object.fromEntries(samples.map((s) => [s.id, s])); + + const sortedReactions = [...reactions].sort((a, b) => { + const aDate = new Date(a.updated_at || a.created_at || 0).getTime(); + const bDate = new Date(b.updated_at || b.created_at || 0).getTime(); + return bDate - aDate; + }); + + const svgUrlFor = (sid) => { + const file = sampleById[sid]?.sample_svg_file; + return file ? `${window.location.origin}/images/samples/${file}` : null; + }; + + const reactionSvgUrlFor = (r) => { + const file = r.reaction_svg_file; + return file ? `${window.location.origin}/images/reactions/${file}` : null; + }; + + const parentOf = {}; + samples.forEach((s) => { + if (s.ancestry && s.ancestry !== '/') { + const parentIdStr = s.ancestry.split('/').filter(Boolean).pop(); + const parentId = Number(parentIdStr); + if (!Number.isNaN(parentId)) { + parentOf[s.id] = parentId; + } + } + }); + + const reactionSamples = {}; + sortedReactions.forEach((r) => { + reactionSamples[r.id] = new Set([ + ...(r.starting_material_ids || []), + ...(r.product_ids || []), + ]); + }); + + const reactionParent = {}; + sortedReactions.forEach((r) => { + const candidateIds = [ + ...(r.starting_material_ids || []), + ...(r.product_ids || []), + ]; + + const splitSampleId = candidateIds.find((sid) => parentOf[sid]); + if (!splitSampleId) return; + + const parentSampleId = parentOf[splitSampleId]; + + for (const other of sortedReactions) { + if (other.id === r.id) continue; + if (reactionSamples[other.id]?.has(parentSampleId)) { + reactionParent[r.id] = other.id; + break; + } + } + }); + + const blockPos = {}; + let rootIndex = 0; + + function resolveRowCollision(x, y, direction) { + let nextX = x; + let safe = false; + while (!safe) { + safe = true; + for (const otherId in blockPos) { + const p = blockPos[otherId]; + if (p.y !== y) continue; + if (Math.abs(p.x - nextX) < MIN_ROW_GAP) { + safe = false; + nextX += direction * MIN_ROW_GAP; + break; + } + } + } + return nextX; + } + + function placeReaction(rid) { + if (blockPos[rid]) return; + + const parentId = reactionParent[rid]; + + if (!parentId) { + const x = resolveRowCollision(0, rootIndex * V_BLOCK, 1); + blockPos[rid] = { x, y: rootIndex * V_BLOCK }; + rootIndex++; + return; + } + + placeReaction(parentId); + + const parentPos = blockPos[parentId]; + const parentReaction = sortedReactions.find((r) => r.id === parentId); + + const currentReaction = sortedReactions.find((r) => r.id === rid); + const splitSampleId = [ + ...(currentReaction?.starting_material_ids || []), + ...(currentReaction?.product_ids || []), + ].find((sid) => parentOf[sid]); + + let direction = -1; + + if (splitSampleId && parentReaction) { + const parentSampleId = parentOf[splitSampleId]; + + if (parentReaction.product_ids?.includes(parentSampleId)) { + direction = + parentReaction.product_ids.indexOf(parentSampleId) === 0 ? -1 : 1; + } + + if (parentReaction.starting_material_ids?.includes(parentSampleId)) { + direction = + parentReaction.starting_material_ids.indexOf(parentSampleId) === 0 + ? -1 + : 1; + } + } + + let x = parentPos.x + direction * BRANCH_X; + const y = parentPos.y + V_BLOCK; + x = resolveRowCollision(x, y, direction); + + blockPos[rid] = { x, y }; + } + + sortedReactions.forEach((r) => placeReaction(r.id)); + + const usedSamples = new Set(); + + sortedReactions.forEach((r) => { + const pos = blockPos[r.id]; + if (!pos) return; + + const baseX = pos.x; + const baseY = pos.y; + + const starting = r.starting_material_ids || []; + const reagents = r.reactant_ids || []; + const products = r.product_ids || []; + + starting.forEach((id) => usedSamples.add(id)); + reagents.forEach((id) => usedSamples.add(id)); + products.forEach((id) => usedSamples.add(id)); + + const startX = baseX - ((starting.length - 1) * H_GAP) / 2; + + starting.forEach((sid, i) => { + nodes.push({ + id: `sample-${sid}`, + type: 'sample', + position: { x: startX + i * H_GAP, y: baseY }, + data: { + label: sampleById[sid]?.short_label || 'Sample', + image: svgUrlFor(sid), + }, + style: { backgroundColor: '#fce5b3', border: '2px solid #eb9800' }, + }); + + edges.push({ + id: `edge-start-${sid}-${r.id}`, + source: `sample-${sid}`, + target: `reaction-${r.id}`, + label: 'has starting material', + labelStyle: { fontSize: 10, fill: '#4338ca' }, + style: { stroke: '#4338ca', strokeWidth: 2 }, + }); + }); + + const reactionY = baseY + V_GAP; + + nodes.push({ + id: `reaction-${r.id}`, + type: 'reaction', + position: { x: baseX, y: reactionY }, + data: { + label: `${r.short_label || 'Reaction'}${r.name ? `: ${r.name}` : ''}`, + reactionImage: reactionSvgUrlFor(r), + reactionName: r.name || '', + reactionShortLabel: r.short_label || '', + reactionCreatedAt: r.created_at || null, + reactionUpdatedAt: r.updated_at || null, + reactionPerformedAt: r.updated_at || r.created_at || null, + reactionReagentNodeIds: (reagents || []).map((sid) => `sample-${sid}`), + }, + style: { + backgroundColor: '#f87171', + border: '2px solid #b91c1c', + color: 'white', + }, + }); + + const REAGENT_NEAR_OFFSET = 160; + const REAGENT_STEP = 160; + + if (reagents.length === 1) { + const sid = reagents[0]; + nodes.push({ + id: `sample-${sid}`, + type: 'sample', + position: { + x: baseX + REAGENT_NEAR_OFFSET, + y: reactionY, + }, + data: { + label: sampleById[sid]?.short_label || 'Reagent', + image: svgUrlFor(sid), + }, + style: { backgroundColor: '#dbeafe', border: '2px solid #3b82f6' }, + }); + } else if (reagents.length === 2) { + const leftSid = reagents[0]; + const rightSid = reagents[1]; + + nodes.push({ + id: `sample-${leftSid}`, + type: 'sample', + position: { + x: baseX - REAGENT_NEAR_OFFSET, + y: reactionY, + }, + data: { + label: sampleById[leftSid]?.short_label || 'Reagent', + image: svgUrlFor(leftSid), + }, + style: { backgroundColor: '#dbeafe', border: '2px solid #3b82f6' }, + }); + + nodes.push({ + id: `sample-${rightSid}`, + type: 'sample', + position: { + x: baseX + REAGENT_NEAR_OFFSET, + y: reactionY, + }, + data: { + label: sampleById[rightSid]?.short_label || 'Reagent', + image: svgUrlFor(rightSid), + }, + style: { backgroundColor: '#dbeafe', border: '2px solid #3b82f6' }, + }); + } else { + reagents.forEach((sid, i) => { + nodes.push({ + id: `sample-${sid}`, + type: 'sample', + position: { + x: baseX + REAGENT_NEAR_OFFSET + i * REAGENT_STEP, + y: reactionY, + }, + data: { + label: sampleById[sid]?.short_label || 'Reagent', + image: svgUrlFor(sid), + }, + style: { backgroundColor: '#dbeafe', border: '2px solid #3b82f6' }, + }); + }); + } + + const prodX = baseX - ((products.length - 1) * H_GAP) / 2; + + products.forEach((sid, i) => { + nodes.push({ + id: `sample-${sid}`, + type: 'sample', + position: { x: prodX + i * H_GAP, y: baseY + 2 * V_GAP }, + data: { + label: sampleById[sid]?.short_label || 'Sample', + image: svgUrlFor(sid), + }, + style: { backgroundColor: '#fce5b3', border: '2px solid #eb9800' }, + }); + + edges.push({ + id: `edge-prod-${r.id}-${sid}`, + source: `reaction-${r.id}`, + target: `sample-${sid}`, + label: 'has product', + labelStyle: { fontSize: 12, fill: '#16a34a' }, + style: { stroke: '#16a34a', strokeWidth: 2 }, + }); + }); + }); + + Object.entries(parentOf).forEach(([child, parent]) => { + edges.push({ + id: `edge-split-${parent}-${child}`, + source: `sample-${parent}`, + target: `sample-${child}`, + label: 'has split sample', + labelStyle: { fontSize: 10, fill: '#6b7280' }, + style: { stroke: '#6b7280', strokeWidth: 1.5 }, + }); + }); + + let y = 0; + samples.forEach((s) => { + if (!usedSamples.has(s.id)) { + nodes.push({ + id: `unused-${s.id}`, + type: 'sample', + position: { x: -850, y }, + data: { + label: s.short_label || 'Unused Sample', + image: s.sample_svg_file + ? `${window.location.origin}/images/samples/${s.sample_svg_file}` + : null, + }, + style: { backgroundColor: '#e5e7eb', border: '1px solid #9ca3af' }, + }); + y += 80; + } + }); + + return { nodes, edges }; +} + +export default class ExplorerContainer extends Component { + state = { isLoading: true, nodes: [], edges: [], error: null }; + + componentDidMount() { + this.loadExplorerData(); + } + + async loadExplorerData() { + try { + const { currentCollection } = UIStore.getState(); + const res = await ExplorerFetcher.fetch({ + collectionId: currentCollection.id, + }); + + const { nodes, edges } = positionNodesByReaction( + res.samples, + res.reactions + ); + + this.setState({ nodes, edges, isLoading: false }); + } catch (e) { + this.setState({ error: e, isLoading: false }); + } + } + + render() { + const { nodes, edges, isLoading, error } = this.state; + const { explorer } = this.props; + + if (isLoading) return
Loading…
; + if (error) return
{error.message}
; + + return ( + +

Explorer

+ + + } + > + +
+ ); + } +} + diff --git a/app/javascript/src/components/contextActions/ReportUtilButton.js b/app/javascript/src/components/contextActions/ReportUtilButton.js index 4849262f94..b6ae7cf5aa 100644 --- a/app/javascript/src/components/contextActions/ReportUtilButton.js +++ b/app/javascript/src/components/contextActions/ReportUtilButton.js @@ -54,6 +54,11 @@ function ReportUtilButton() { Reference Manager + + Explorer + + + {enableComputedProps && ( <> diff --git a/app/javascript/src/fetchers/ExplorerFetcher.js b/app/javascript/src/fetchers/ExplorerFetcher.js new file mode 100644 index 0000000000..8d87f91405 --- /dev/null +++ b/app/javascript/src/fetchers/ExplorerFetcher.js @@ -0,0 +1,31 @@ +import 'whatwg-fetch'; + +export default class ExplorerFetcher { + static fetch({ collectionId }) { + if (!collectionId) { + throw new Error('collectionId is required'); + } + + const url = new URL(`${window.location.origin}/api/v1/explorer?collection_id=${collectionId}`); + url.search = new URLSearchParams({ collection_id: collectionId }); + + return fetch(url.href, { + credentials: 'same-origin' + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Network response was not ok (${response.status})`); + } + return response.json(); + }) + .then((response) => ({ + samples: response.samples || [], + reactions: response.reactions || [], + molecules: response.molecules || [] + })) + .catch((error) => { + console.error('ElementFetcher fetch error:', error); + }); + } + +} \ No newline at end of file diff --git a/app/javascript/src/models/Explorer.js b/app/javascript/src/models/Explorer.js new file mode 100644 index 0000000000..5929e9a250 --- /dev/null +++ b/app/javascript/src/models/Explorer.js @@ -0,0 +1,11 @@ +import Element from 'src/models/Element'; + +export default class Explorer extends Element { + static buildEmpty() { + let explorer = new Explorer({ + type: 'explorer' + }); + + return explorer; + } +} diff --git a/app/javascript/src/stores/alt/actions/ElementActions.js b/app/javascript/src/stores/alt/actions/ElementActions.js index 206bcafc06..1b26e9ef34 100644 --- a/app/javascript/src/stores/alt/actions/ElementActions.js +++ b/app/javascript/src/stores/alt/actions/ElementActions.js @@ -36,6 +36,7 @@ import Screen from 'src/models/Screen'; import ResearchPlan from 'src/models/ResearchPlan'; import DeviceDescription from 'src/models/DeviceDescription'; import Report from 'src/models/Report'; +import Explorer from 'src/models/Explorer'; import Format from 'src/models/Format'; import Graph from 'src/models/Graph'; import ComputeTask from 'src/models/ComputeTask'; @@ -1275,6 +1276,12 @@ class ElementActions { return LiteratureMap.buildEmpty(); } + // -- Explorer -- + showExplorerDetails() { + return Explorer.buildEmpty(); + } + + // -- Prediction -- showPredictionContainer() { return Prediction.buildEmpty(); diff --git a/app/javascript/src/stores/alt/stores/ElementStore.js b/app/javascript/src/stores/alt/stores/ElementStore.js index f40de60893..be5bede850 100644 --- a/app/javascript/src/stores/alt/stores/ElementStore.js +++ b/app/javascript/src/stores/alt/stores/ElementStore.js @@ -275,6 +275,7 @@ class ElementStore { ElementActions.generateEmptyVesselTemplate, ElementActions.generateEmptySequenceBasedMacromoleculeSample, ElementActions.showReportContainer, + ElementActions.showExplorerDetails, ElementActions.showFormatContainer, ElementActions.showComputedPropsGraph, ElementActions.showComputedPropsTasks,