All files / src/components MoleculeSpinner.tsx

64.28% Statements 81/126
56.52% Branches 13/23
50% Functions 2/4
64.28% Lines 81/126

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 2361x   1x 1x   1x 1x   1x                                                                                                                     1x 1x   1x 1x   1x       1x         1x                                                   1x 1x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x   14x 14x 14x 14x 14x   14x 14x 14x 14x 14x 14x 14x   14x 13x 13x 13x 13x   13x 11x 11x 11x   2x 13x   13x 2x 2x   1x 1x 1x 1x 1x   2x 2x 2x               2x   13x 13x 2x 2x 14x   14x 13x 13x                                                               14x   14x 13x 14x   14x 13x 14x   14x 14x 14x                 14x 14x 14x  
import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
import type { StructureRecordType } from '@/helpers/pubchem';
import { resolveMolecule } from '@/utils/molecule/resolveMolecule';
import { stripHydrogens, type Molecule } from '@/utils/molecule/sdf';
import type { MoleculeSceneHandle } from '@/utils/molecule/scene';
import { Logger } from '@/utils/Logger';
import { getErrorMessage } from '@/helpers/exceptions';
 
const logger = new Logger('MoleculeSpinner');
 
/**
 * Where a {@link MoleculeSpinner} is in its resolve-and-render lifecycle.
 * @category Components
 * @source
 */
export type MoleculeSpinnerState = 'idle' | 'loading' | 'ready' | 'unavailable';
 
/**
 * What a {@link MoleculeSpinner} resolved, reported through
 * `MoleculeSpinnerProps.onStatusChange`. Everything but `state` is present only once a
 * structure has been drawn.
 * @category Components
 * @source
 */
export interface MoleculeSpinnerStatus {
  /** Current lifecycle state. */
  state: MoleculeSpinnerState;
  /** The CID the query resolved to. */
  cid?: PubChemCID;
  /** Whether PubChem served a real 3D conformer or the flat 2D fallback. */
  recordType?: StructureRecordType;
  /** Atoms actually drawn, after any hydrogen stripping. */
  atomCount?: number;
  /** Bonds actually drawn, after any hydrogen stripping. */
  bondCount?: number;
  /** Wall-clock milliseconds spent resolving and fetching from PubChem. */
  elapsedMs?: number;
}
 
/**
 * Props for {@link MoleculeSpinner}.
 * - `query` - The search query to depict. Changing it resolves a new structure.
 * - `size` - Canvas edge length in CSS pixels. Defaults to 128, matching the cube it replaces.
 * - `spinSeconds` - Seconds per revolution. Changing it retimes without rebuilding.
 * - `atomScale` - Multiplier on the uniform atom radius; lower is more stick, less ball.
 * - `showHydrogens` - Draw explicit hydrogens. On by default; the full structure reads
 *   better than a bare skeleton.
 * - `pending` - Rendered while the structure is still being resolved, before anything is
 *   known. Kept separate from `fallback` so callers never flash one graphic and then
 *   replace it with another.
 * - `fallback` - Rendered only once it is settled that there is nothing to draw: no query,
 *   no PubChem match, or no usable WebGL context.
 * - `onStatusChange` - Notified on every lifecycle transition.
 * @category Components
 * @source
 */
export interface MoleculeSpinnerProps {
  query: string;
  size?: number;
  spinSeconds?: number;
  atomScale?: number;
  showHydrogens?: boolean;
  pending?: ReactNode;
  fallback?: ReactNode;
  onStatusChange?: (status: MoleculeSpinnerStatus) => void;
}
 
/** Default canvas edge length, matching the cubane loader it stands in for. */
const DEFAULT_SIZE = 128;
 
/** Default seconds per revolution — slow enough to read as deliberate, not frantic. */
const DEFAULT_SPIN_SECONDS = 6;
 
/**
 * Reports whether the viewer has asked for reduced motion.
 * @returns True when `prefers-reduced-motion: reduce` matches
 * @source
 */
function prefersReducedMotion(): boolean {
  return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
}
 
/**
 * Renders the molecule a search query refers to as a slowly rotating ball-and-stick model,
 * as a drop-in replacement for a generic loading spinner.
 *
 * The structure comes from PubChem: the query is resolved to a CID, and its 3D conformer
 * fetched — falling back to the flat 2D record for the ionic and inorganic compounds
 * PubChem computes no conformer for, which a planar wobble keeps legible. When nothing
 * resolves, `fallback` is rendered instead, so the caller always has something on screen.
 *
 * three.js is loaded through a dynamic import, so the WebGL renderer stays out of the
 * initial bundle and only downloads once a structure is ready to draw. The component is
 * memoized and holds its scene in a ref: the surrounding loading UI re-renders constantly
 * as results arrive, and rebuilding the scene on each of those would restart the animation
 * and leak WebGL contexts.
 * @component
 * @category Components
 * @param props - The spinner props (see {@link MoleculeSpinnerProps}).
 * @returns The rotating structure, or the supplied fallback.
 * @example
 * ```tsx
 * <MoleculeSpinner
 *   query={executedQuery}
 *   fallback={<IconSpinner><img src="/static/images/cubane-loader.png" /></IconSpinner>}
 * />
 * ```
 * @source
 */
export const MoleculeSpinner = memo(function MoleculeSpinner(props: MoleculeSpinnerProps) {
  const {
    query,
    size = DEFAULT_SIZE,
    spinSeconds = DEFAULT_SPIN_SECONDS,
    atomScale,
    showHydrogens = true,
    pending,
    fallback,
    onStatusChange,
  } = props;
 
  const [molecule, setMolecule] = useState<Molecule | undefined>();
  const [unavailable, setUnavailable] = useState(false);
  const [sceneFailed, setSceneFailed] = useState(false);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const handleRef = useRef<MoleculeSceneHandle | undefined>(undefined);
 
  // Read through refs inside effects that must not re-run when these change.
  const statusRef = useRef(onStatusChange);
  statusRef.current = onStatusChange;
  const sizeRef = useRef(size);
  sizeRef.current = size;
  const spinRef = useRef(spinSeconds);
  spinRef.current = spinSeconds;
 
  useEffect(() => {
    let cancelled = false;
    setMolecule(undefined);
    setUnavailable(false);
    setSceneFailed(false);
 
    if (query.trim() === '') {
      statusRef.current?.({ state: 'idle' });
      return;
    }
 
    statusRef.current?.({ state: 'loading' });
    const startedAt = performance.now();
 
    const resolve = async () => {
      const resolved = await resolveMolecule(query);
      if (cancelled) return;
 
      if (!resolved) {
        setUnavailable(true);
        statusRef.current?.({ state: 'unavailable', elapsedMs: performance.now() - startedAt });
        return;
      }
 
      const drawn = showHydrogens ? resolved.molecule : stripHydrogens(resolved.molecule);
      setMolecule(drawn);
      statusRef.current?.({
        state: 'ready',
        cid: resolved.cid,
        recordType: resolved.recordType,
        atomCount: drawn.atoms.length,
        bondCount: drawn.bonds.length,
        elapsedMs: performance.now() - startedAt,
      });
    };
 
    void resolve();
    return () => {
      cancelled = true;
    };
  }, [query, showHydrogens]);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!molecule || !canvas) return;
 
    let cancelled = false;
    const build = async () => {
      try {
        const { createMoleculeScene } = await import('@/utils/molecule/scene');
        if (cancelled) return;
        handleRef.current = createMoleculeScene(canvas, molecule, {
          size: sizeRef.current,
          spinSeconds: spinRef.current,
          atomScale,
          reducedMotion: prefersReducedMotion(),
        });
      } catch (error) {
        // WebGL can be unavailable entirely (blocklisted GPU, too many live contexts,
        // a non-rendering test environment). Drop back to the caller's fallback rather
        // than leaving an empty canvas or an unhandled rejection.
        logger.error(`Could not render molecule; falling back: ${getErrorMessage(error)}`, {
          error,
        });
        if (cancelled) return;
        setSceneFailed(true);
        statusRef.current?.({ state: 'unavailable' });
      }
    };
 
    void build();
    return () => {
      cancelled = true;
      handleRef.current?.dispose();
      handleRef.current = undefined;
    };
  }, [molecule, atomScale]);
 
  useEffect(() => {
    handleRef.current?.setSize(size);
  }, [size]);
 
  useEffect(() => {
    handleRef.current?.setSpinSeconds(spinSeconds);
  }, [spinSeconds]);
 
  // Derived synchronously rather than from the status callback, so the very first render
  // with a query already shows `pending` — there is no frame where `fallback` flashes.
  if (molecule && !sceneFailed) {
    return (
      <canvas
        ref={canvasRef}
        data-testid="molecule-spinner"
        style={{ width: size, height: size, display: 'block' }}
      />
    );
  }
  if (query.trim() === '' || unavailable || sceneFailed) return <>{fallback}</>;
  return <>{pending}</>;
});