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 && (
+

+ )}
+
+ )}
+
+
{
+ 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,