/** Typed client for the MuSProt explorer API. */ const API = (import.meta.env.VITE_PROTEIN_API_BASE as string | undefined) || '/api'; export type Fidelity = 'identical' | 'low' | 'medium' | 'high' | 'NA'; export interface LabelValue { label: string; value: number; } export interface Overview { generated: string; counts: { observations: number; sequences: number; multi_state_sequences: number; state_clusters: number; pdb_entries: number; uniprot_accessions: number; transitions: number; cross_state_transitions: number; }; states_per_sequence: LabelValue[]; observations_per_sequence: LabelValue[]; sequence_length: LabelValue[]; resolution: LabelValue[]; experimental_method: LabelValue[]; binding_status: LabelValue[]; chain_composition: LabelValue[]; nucleic_acid_binding: LabelValue[]; release_year: LabelValue[]; pair_fidelity: Array; state_fidelity: Array<{ label: string; state_pairs: number; observation_pairs: number }>; pair_similarity: { bin_width: number; all: number[]; cross_state: number[] }; tm_aln: { bin_width: number; all: number[] }; rmsd: { bin_width: number; all: number[] }; coverage: { bin_width: number; all: number[] }; outer_graph?: { level: string; groups: number; covered_sequences: number; coverage: number; edges: number; group_sizes: LabelValue[]; largest: Array<{ hset: string; label: string; n_sequences: number; n_obs: number; n_multistate: number }>; }; order?: { validity: LabelValue[]; evidence: Array; with_ligand_change: Array<{ label: string; with: number; without: number }>; }; } export interface SequenceSummary { sequence_id: string; uniprot_id: string | null; length: number | null; n_obs: number; n_states: number; n_entries: number; n_holo: number; n_apo: number; n_cross_pairs: number; largest_state: number; rep_pdb: string; rep_chain: string; cath_superfamily: string | null; methods: string[]; first_release: string | null; last_release: string | null; top_function: string | null; ecod_hset: string[]; pfam_ids: string[]; } export interface EcodNames { families: Record; homologies: Record; } export interface EcodGroup { hset: string[]; label: string; n_sequences: number; n_obs: number; n_multistate: number; n_states: number; } export interface StateInfo { state_id: number; n_members: number; } export interface Member { pdb_id: string; auth_asym_id: string; state_id: number; uniprot_id: string | null; experimental_method: string | null; resolution: number | null; binding_status: string | null; chain_composition: string | null; binders: string[]; release_date: string | null; resolved_coverage: number | null; cath_id: string | null; pfam_id: string | null; ecod_id: string | null; ecod_fid: string | null; } export interface StatePair { a: number; b: number; similarity: number | null; fidelity: string | null; n_pairs: number; } export interface RankedFunction { function: string; score: number | null; } export interface SequenceDetail extends SequenceSummary { states: StateInfo[]; members: Member[]; state_pairs: StatePair[]; heatmap_truncated: boolean; sequence?: string; functions?: RankedFunction[]; cath_superfamilies?: string[]; ecod_group: EcodGroup | null; ecod_names: EcodNames; } export const ENERGY_TERMS = ['Rosetta', 'FoldX', 'EvoEF2', 'RW', 'RW+'] as const; export type EnergyTerm = (typeof ENERGY_TERMS)[number]; export interface ChainDetail { pdb_id: string; auth_asym_id: string; sequence_id: string; uniprot_id: string | null; state_id: number | null; sequence: string; sequence_length: number | null; modeled_sequence_length: number | null; resolved_ranges: Array<[number, number]>; n_resolved_aa: number | null; resolved_coverage: number | null; binders: string[]; binding_status: string | null; experimental_method: string | null; resolution: number | null; pH: number | null; temp_K: number | null; experimental_details: string | null; chain_composition: string | null; nucleic_acid_binding: string | null; release_date: string | null; cath_domains: string[]; cath_superfamilies: string[]; pfam_ids: string[]; ecod_domains: string[]; ecod_fids: string[]; ecod_names: EcodNames; energies: Record; scoring: { n_seqres: number | null; n_scored: number | null; scored_coverage: number | null; foldx_n_scored: number | null; foldx_coverage: number | null; }; functions: RankedFunction[]; family: (SequenceSummary & { states: StateInfo[] }) | null; } export interface Transition { pdb_id: string; auth_asym_id: string; state_id: number | null; tm_aln: number | null; rmsd: number | null; coverage: number | null; similarity: number | null; fidelity: string | null; state_similarity: number | null; state_fidelity: string | null; delta: Record; order: OrderChange; experimental_method?: string | null; resolution?: number | null; binding_status?: string | null; chain_composition?: string | null; binders?: string[]; release_date?: string | null; } /** Observed order/disorder change from A to B (resolved-state sense, not intrinsic disorder). */ export interface OrderChange { valid: boolean | null; reason: string | null; n_shared_resolved: number | null; n_ordered: number | null; n_disordered: number | null; ordered_ranges: Array<[number, number]>; disordered_ranges: Array<[number, number]>; longest_ordered: number | null; longest_disordered: number | null; n_terminal_ordered: number | null; n_internal_ordered: number | null; evidence: string | null; with_ligand_change: boolean | null; } export interface OuterGraph { groups: Array; names: EcodNames['homologies']; } export interface InnerGraph { sequence: SequenceSummary; nodes: Member[]; n_total: number; states: StateInfo[]; state_pairs: StatePair[]; edges: Array<{ s: number; t: number; sim: number | null; fid: string | null }>; } export interface TransitionsResponse { pdb_id: string; auth_asym_id: string; state_id: number | null; sequence_id: string; transitions: Transition[]; } export interface ChainHit { pdb_id: string; auth_asym_id: string; sequence_id: string; uniprot_id: string | null; state_id: number | null; sequence_length: number | null; } export interface SearchResponse { query: string; kind: 'empty' | 'chain' | 'entry' | 'sequence' | 'uniprot' | 'text' | 'unavailable'; chains: ChainHit[]; sequences: SequenceSummary[]; entry_chains?: ChainHit[]; total?: number; } export interface SequenceListResponse { total: number; offset: number; items: SequenceSummary[]; } export interface BrowseParams { sort?: string; order?: 'asc' | 'desc'; q?: string; min_states?: number; min_obs?: number; method?: string; ligand?: 'both' | 'holo' | 'apo'; cath?: string; offset?: number; limit?: number; ids?: string; ecod?: string; pfam?: string; } export class ApiError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } const TIMEOUT_MS = 60_000; async function get(path: string, signal?: AbortSignal): Promise { // give up after TIMEOUT_MS so a stalled backend shows an error instead of loading forever const ctrl = new AbortController(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, TIMEOUT_MS); const onAbort = () => ctrl.abort(); signal?.addEventListener('abort', onAbort); let res: Response; try { res = await fetch(`${API}${path}`, { signal: ctrl.signal, headers: { Accept: 'application/json' } }); } catch (err) { if (timedOut) throw new ApiError(504, 'The server is taking too long to respond. Please try again in a moment.'); throw err; } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); } if (!res.ok) { let detail = res.statusText; try { const body = await res.json(); detail = body.detail || detail; } catch { /* keep status text */ } throw new ApiError(res.status, detail); } return res.json() as Promise; } function qs(params: object): string { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); } const s = sp.toString(); return s ? `?${s}` : ''; } const enc = encodeURIComponent; export const api = { overview: (signal?: AbortSignal) => get('/protein/overview', signal), search: (q: string, signal?: AbortSignal) => get(`/protein/search${qs({ q })}`, signal), sequences: (params: BrowseParams, signal?: AbortSignal) => get(`/protein/sequences${qs(params)}`, signal), sequence: (id: string, signal?: AbortSignal) => get(`/protein/sequence/${enc(id)}`, signal), chain: (pdb: string, chain: string, signal?: AbortSignal) => get(`/protein/chain/${enc(pdb)}/${enc(chain)}`, signal), transitions: (pdb: string, chain: string, signal?: AbortSignal) => get(`/protein/chain/${enc(pdb)}/${enc(chain)}/transitions`, signal), outerGraph: (seqIds: string[], signal?: AbortSignal, perGroup = 18) => get(`/protein/graph/outer${qs({ seq: seqIds.join(','), per_group: perGroup })}`, signal), innerGraph: (id: string, maxNodes = 60, signal?: AbortSignal) => get(`/protein/graph/inner/${enc(id)}${qs({ max_nodes: maxNodes })}`, signal), docs: async (signal?: AbortSignal) => { const res = await fetch(`${API}/protein/docs`, { signal }); if (!res.ok) throw new ApiError(res.status, 'Documentation is not available'); return res.text(); }, downloadUrl: `${API}/protein/download/db`, }; /** Entry title from RCSB (best effort; the site works without it). */ const titleCache = new Map>(); export function rcsbTitle(pdbId: string): Promise { const id = pdbId.toUpperCase(); let p = titleCache.get(id); if (!p) { p = fetch(`https://data.rcsb.org/rest/v1/core/entry/${id}`) .then((r) => (r.ok ? r.json() : null)) .then((d) => (d?.struct?.title as string | undefined) ?? null) .catch(() => null); titleCache.set(id, p); } return p; } /** Chemical component names from RCSB (best effort). */ const compCache = new Map>(); export function rcsbComponentName(compId: string): Promise { const id = compId.toUpperCase(); let p = compCache.get(id); if (!p) { p = fetch(`https://data.rcsb.org/rest/v1/core/chemcomp/${enc(id)}`) .then((r) => (r.ok ? r.json() : null)) .then((d) => (d?.chem_comp?.name as string | undefined) ?? null) .catch(() => null); compCache.set(id, p); } return p; } /** Pfam family names from InterPro (best effort). */ const pfamCache = new Map>(); export function pfamName(id: string): Promise { const key = id.toUpperCase(); let p = pfamCache.get(key); if (!p) { p = fetch(`https://www.ebi.ac.uk/interpro/api/entry/pfam/${enc(key)}/`) .then((r) => (r.ok ? r.json() : null)) .then((d) => (d?.metadata?.name?.name as string | undefined) ?? null) .catch(() => null); pfamCache.set(key, p); } return p; }