MuSProt / frontend /src /lib /api.ts
WinslowFan's picture
Claude Opus 5.5
Serve SQLite from local disk instead of the bucket mount
3d86d3b
Raw History Blame Contribute Delete
11.8 kB
/** 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<LabelValue & { cross_state: number }>;
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<LabelValue & { cross_state: number }>;
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<string, { t_name: string; f_name: string }>;
homologies: Record<string, { architecture: string; x_name: string; h_name: string }>;
}
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<EnergyTerm, number | null>;
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<EnergyTerm, number | null>;
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<EcodGroup & { sequences: SequenceSummary[] }>;
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<T>(path: string, signal?: AbortSignal): Promise<T> {
// 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<T>;
}
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<Overview>('/protein/overview', signal),
search: (q: string, signal?: AbortSignal) => get<SearchResponse>(`/protein/search${qs({ q })}`, signal),
sequences: (params: BrowseParams, signal?: AbortSignal) =>
get<SequenceListResponse>(`/protein/sequences${qs(params)}`, signal),
sequence: (id: string, signal?: AbortSignal) => get<SequenceDetail>(`/protein/sequence/${enc(id)}`, signal),
chain: (pdb: string, chain: string, signal?: AbortSignal) =>
get<ChainDetail>(`/protein/chain/${enc(pdb)}/${enc(chain)}`, signal),
transitions: (pdb: string, chain: string, signal?: AbortSignal) =>
get<TransitionsResponse>(`/protein/chain/${enc(pdb)}/${enc(chain)}/transitions`, signal),
outerGraph: (seqIds: string[], signal?: AbortSignal, perGroup = 18) =>
get<OuterGraph>(`/protein/graph/outer${qs({ seq: seqIds.join(','), per_group: perGroup })}`, signal),
innerGraph: (id: string, maxNodes = 60, signal?: AbortSignal) =>
get<InnerGraph>(`/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<string, Promise<string | null>>();
export function rcsbTitle(pdbId: string): Promise<string | null> {
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<string, Promise<string | null>>();
export function rcsbComponentName(compId: string): Promise<string | null> {
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<string, Promise<string | null>>();
export function pfamName(id: string): Promise<string | null> {
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;
}