noisemarseille commited on
Commit
6c4ade3
·
1 Parent(s): 56f6815

basic functionalities

Browse files
.gitignore CHANGED
@@ -3,3 +3,4 @@ __pycache__/
3
  .venv/
4
  venv/
5
  .env
 
 
3
  .venv/
4
  venv/
5
  .env
6
+ saved_models/
app.py CHANGED
@@ -1,11 +1,85 @@
1
  import os
 
 
 
2
 
 
3
  from flask import Flask, jsonify, render_template, request
4
 
5
  from pipeline import run_pipeline
 
 
 
 
6
 
7
  app = Flask(__name__)
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  @app.route("/", methods=["GET"])
11
  def index():
@@ -23,9 +97,54 @@ def analyze():
23
  except Exception as e:
24
  print(f"[analyze] unexpected failure: {e}")
25
  return jsonify({"error": "Analysis failed -- check server logs."}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  return jsonify(result)
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  if __name__ == "__main__":
30
  port = int(os.environ.get("PORT", "7860"))
31
  debug = os.environ.get("FLASK_DEBUG", "0").lower() in ("1", "true", "yes")
 
1
  import os
2
+ import threading
3
+ import time
4
+ import uuid
5
 
6
+ import numpy as np
7
  from flask import Flask, jsonify, render_template, request
8
 
9
  from pipeline import run_pipeline
10
+ from utils.genome_neighbor_retrieval import GenomeNeighborRetriever
11
+ from utils.protein_neighbor_retrieval import ProteinNeighborRetriever
12
+ from utils.gene_context_retrieval import GeneContextRetriever
13
+ from utils.synteny import build_synteny, neighbor_track, query_track
14
 
15
  app = Flask(__name__)
16
 
17
+ # Background neighbor-search jobs: the imgvr_inphared indexes are large
18
+ # enough (GB-scale, network-mounted) that a cold search can take tens of
19
+ # seconds, so /analyze starts them and returns immediately; the frontend
20
+ # polls /job/<id> (same pattern as phlam-demo's HOMOLOGY_JOBS/NEIGHBOR_JOBS).
21
+ JOBS = {}
22
+ _JOBS_LOCK = threading.Lock()
23
+
24
+ # Query genome data kept for a later /synteny call (embeddings aren't sent
25
+ # back to the client, so they're cached here keyed by analysis_id).
26
+ ANALYSES = {}
27
+
28
+ _genome_retriever = None
29
+ _protein_retriever = None
30
+ _gene_context_retriever = None
31
+
32
+
33
+ def _get_genome_retriever():
34
+ global _genome_retriever
35
+ if _genome_retriever is None:
36
+ _genome_retriever = GenomeNeighborRetriever()
37
+ return _genome_retriever
38
+
39
+
40
+ def _get_protein_retriever():
41
+ global _protein_retriever
42
+ if _protein_retriever is None:
43
+ _protein_retriever = ProteinNeighborRetriever()
44
+ return _protein_retriever
45
+
46
+
47
+ def _get_gene_context_retriever():
48
+ global _gene_context_retriever
49
+ if _gene_context_retriever is None:
50
+ _gene_context_retriever = GeneContextRetriever()
51
+ return _gene_context_retriever
52
+
53
+
54
+ def _prune(store, keep=8):
55
+ if len(store) <= keep:
56
+ return
57
+ stale = sorted(store, key=lambda k: store[k]["created"])[:-keep]
58
+ for k in stale:
59
+ store.pop(k, None)
60
+
61
+
62
+ def _run_job(job_id, fn):
63
+ job = JOBS.get(job_id)
64
+ if job is None:
65
+ return
66
+ try:
67
+ job["result"] = fn()
68
+ job["status"] = "done"
69
+ except Exception as e:
70
+ print(f"[job {job_id}] failed: {e}")
71
+ job["status"] = "error"
72
+ job["error"] = str(e)
73
+
74
+
75
+ def _start_job(fn):
76
+ job_id = uuid.uuid4().hex
77
+ JOBS[job_id] = {"status": "pending", "result": None, "error": None, "created": time.time()}
78
+ with _JOBS_LOCK:
79
+ _prune(JOBS)
80
+ threading.Thread(target=_run_job, args=(job_id, fn), daemon=True).start()
81
+ return job_id
82
+
83
 
84
  @app.route("/", methods=["GET"])
85
  def index():
 
97
  except Exception as e:
98
  print(f"[analyze] unexpected failure: {e}")
99
  return jsonify({"error": "Analysis failed -- check server logs."}), 500
100
+
101
+ esm_embeds = result.pop("esm_embeds")
102
+ phlam_protein = result.pop("phlam_protein")
103
+ phlam_genome = np.asarray(result["phlam_genome"], dtype=np.float32)[None, :]
104
+
105
+ analysis_id = uuid.uuid4().hex
106
+ ANALYSES[analysis_id] = {
107
+ "genome_name": result["genome_name"], "genome_length": result["genome_length"], "genes": result["genes"],
108
+ "esm_embeds": esm_embeds, "phlam_protein": phlam_protein, "created": time.time(),
109
+ }
110
+ with _JOBS_LOCK:
111
+ _prune(ANALYSES)
112
+
113
+ jobs = {
114
+ "genome_neighbors": _start_job(lambda: _get_genome_retriever().query(phlam_genome)[0]),
115
+ "protein_neighbors": _start_job(lambda: _get_protein_retriever().query(esm_embeds)),
116
+ "gene_context_neighbors": _start_job(lambda: _get_gene_context_retriever().query(phlam_protein)),
117
+ }
118
+ result["analysis_id"] = analysis_id
119
+ result["jobs"] = jobs
120
  return jsonify(result)
121
 
122
 
123
+ @app.route("/synteny", methods=["POST"])
124
+ def synteny():
125
+ data = request.get_json(silent=True) or {}
126
+ analysis = ANALYSES.get(data.get("analysis_id"))
127
+ if analysis is None:
128
+ return jsonify({"error": "analysis expired -- re-run the analysis"}), 404
129
+ genome_ids = [int(g) for g in data.get("genome_ids", [])][:11] # query + up to 11 neighbors
130
+
131
+ def run():
132
+ tracks = [query_track(analysis["genome_name"], analysis["genome_length"], analysis["genes"],
133
+ analysis["esm_embeds"], analysis["phlam_protein"])]
134
+ tracks += [neighbor_track(gid) for gid in genome_ids]
135
+ return build_synteny(tracks)
136
+
137
+ return jsonify({"job": _start_job(run)})
138
+
139
+
140
+ @app.route("/job/<job_id>", methods=["GET"])
141
+ def job_status(job_id):
142
+ job = JOBS.get(job_id)
143
+ if job is None:
144
+ return jsonify({"status": "unknown"}), 404
145
+ return jsonify({"status": job["status"], "result": job["result"], "error": job["error"]})
146
+
147
+
148
  if __name__ == "__main__":
149
  port = int(os.environ.get("PORT", "7860"))
150
  debug = os.environ.get("FLASK_DEBUG", "0").lower() in ("1", "true", "yes")
phlam/__init__.py ADDED
File without changes
phlam/attention.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.nn.functional as F
5
+
6
+ N_POS_BUCKETS = 65 # 32 left + 32 right + 1 sink
7
+
8
+ # Strand/relation categories (DeBERTa-style vector bias, see RelationalBias in model.py).
9
+ # opp_far splits off convergent/divergent once the pair is farther apart than
10
+ # opp_far_dist, so "these are opposite-strand and close" stays a distinct signal
11
+ # from "these are opposite-strand and it barely matters".
12
+ SAME, CONVERGENT, DIVERGENT, OPP_FAR, GLOBAL, SINK = range(6)
13
+ N_STRAND_CATEGORIES = 6
14
+
15
+
16
+ class GenomicDistanceBucketeer(nn.Module):
17
+ """
18
+ Maps distance magnitudes (in gene count, not bp) to bucket indices: linear
19
+ resolution near zero, geometric growth out to max_dist, last bucket is a
20
+ pure overflow catch-all. Boundaries are derived from (n_bins, max_dist)
21
+ rather than hardcoded, so every bucket stays reachable if max_dist is
22
+ retuned -- a previous hardcoded version silently went stale when the
23
+ model's default max_dist changed and some boundaries fell out of range.
24
+ """
25
+ def __init__(self, n_bins: int = 32, max_dist: int = 600, n_linear: int = 8):
26
+ super().__init__()
27
+ self.n_bins = n_bins
28
+ self.max_dist = max_dist
29
+ self.register_buffer(
30
+ "boundaries", self._build_boundaries(n_bins, max_dist, n_linear)
31
+ )
32
+
33
+ @staticmethod
34
+ def _build_boundaries(n_bins: int, max_dist: int, n_linear: int) -> torch.Tensor:
35
+ n_boundaries = n_bins - 1 # last bin index is the overflow catch-all
36
+ n_linear = min(n_linear, n_boundaries - 1) # leave room for >=1 geometric step
37
+
38
+ # Per-gene resolution starting at 1 (not 0 -- see class docstring).
39
+ linear = list(range(1, n_linear + 1))
40
+
41
+ n_geo = n_boundaries - n_linear
42
+ geo_start = n_linear + 1
43
+ if n_geo > 0 and max_dist > geo_start:
44
+ geo = torch.exp(torch.linspace(math.log(geo_start), math.log(max_dist), n_geo))
45
+ geo = torch.round(geo)
46
+ for i in range(1, len(geo)): # guard against rounding collisions at small n_geo
47
+ if geo[i] <= geo[i - 1]:
48
+ geo[i] = geo[i - 1] + 1
49
+ geo = geo.tolist()
50
+ else:
51
+ geo = []
52
+
53
+ boundaries = torch.tensor(linear + geo, dtype=torch.float32)
54
+ return boundaries[:n_boundaries]
55
+
56
+ def forward(self, abs_diff: torch.Tensor) -> torch.Tensor:
57
+ buckets = torch.bucketize(abs_diff.float(), self.boundaries, right=True)
58
+ is_over_max = abs_diff >= self.max_dist
59
+ dist_buckets = torch.where(is_over_max, self.n_bins - 1, buckets)
60
+ return dist_buckets.clamp(0, self.n_bins - 1).long()
61
+
62
+
63
+ def compute_genomic_bias_indices(strand_ids, mask, is_circular, bucketeer, opp_far_dist: float = 16.0):
64
+ """
65
+ Computes pos_buckets [B,S,S] (for the additive positional bias) and
66
+ orient_cats [B,S,S] (for the DeBERTa-style strand RelationalBias) in one pass, since both
67
+ depend on the same topo_diff / is_protein bookkeeping.
68
+
69
+ pos_buckets (65 entries): signed genomic offset.
70
+ Buckets 0-31: key is LEFT (topo_diff <= 0)
71
+ Buckets 32-63: key is RIGHT (topo_diff > 0)
72
+ Bucket 64: SINK — non-protein pairs
73
+
74
+ orient_cats (6 entries):
75
+ 0: SAME FF or RR
76
+ 1: CONVERGENT >< — left gene fwd, |topo_diff| < opp_far_dist
77
+ 2: DIVERGENT <> — left gene rev, |topo_diff| < opp_far_dist
78
+ 3: OPP_FAR opposite strands but farther apart than opp_far_dist
79
+ 4: GLOBAL CLS-to-anything pairs
80
+ 5: SINK strand-masked, padding
81
+ """
82
+ N_SIGNED = 64
83
+ POS_SINK = 64
84
+
85
+ is_protein = (strand_ids > 0) & (strand_ids < 3) & mask.bool()
86
+ positions = torch.cumsum(is_protein.long(), dim=-1).float()
87
+ linear_diff = positions.unsqueeze(2) - positions.unsqueeze(1)
88
+
89
+ gen_sizes = is_protein.sum(dim=-1, keepdim=True).unsqueeze(-1).float().clamp(min=1)
90
+ circular_diff = (linear_diff + gen_sizes / 2) % gen_sizes - gen_sizes / 2
91
+
92
+ ic = is_circular.view(-1, 1, 1) if is_circular.dim() >= 1 \
93
+ else is_circular.view(1, 1, 1)
94
+ topo_diff = torch.where(ic.bool(), circular_diff, linear_diff)
95
+
96
+ s_q = strand_ids.unsqueeze(2) # [B, S, 1]
97
+ s_k = strand_ids.unsqueeze(1) # [B, 1, S]
98
+
99
+ pp = is_protein.unsqueeze(2) & is_protein.unsqueeze(1)
100
+
101
+ safe_abs = torch.where(pp, topo_diff.abs(),
102
+ torch.full_like(topo_diff, float(bucketeer.max_dist)))
103
+ mag = bucketeer(safe_abs)
104
+ sign_off = (topo_diff > 0).long() * (N_SIGNED // 2)
105
+ pos_buckets = (mag + sign_off).masked_fill(~pp, POS_SINK)
106
+
107
+ known = lambda s: (s == 1) | (s == 2)
108
+ both_known = known(s_q) & known(s_k) & pp
109
+ opp = both_known & (s_q != s_k)
110
+ is_far = topo_diff.abs() >= opp_far_dist
111
+
112
+ query_is_left = topo_diff < 0
113
+ left_is_fwd = torch.where(query_is_left, s_q == 1, s_k == 1)
114
+
115
+ orient_cats = pos_buckets.new_full(pos_buckets.shape, SINK)
116
+ orient_cats.masked_fill_(both_known & ~opp, SAME)
117
+ orient_cats.masked_fill_(opp & ~is_far & left_is_fwd, CONVERGENT)
118
+ orient_cats.masked_fill_(opp & ~is_far & ~left_is_fwd, DIVERGENT)
119
+ orient_cats.masked_fill_(opp & is_far, OPP_FAR)
120
+ # GLOBAL applied last to override any accidental overlap with CLS pairs.
121
+ orient_cats.masked_fill_((s_q == 3) | (s_k == 3), GLOBAL)
122
+
123
+ return pos_buckets, orient_cats
124
+
125
+
126
+ def _relation_scores(content: torch.Tensor, rel_vecs: torch.Tensor, cats: torch.Tensor,
127
+ content_is_col: bool) -> torch.Tensor:
128
+ """
129
+ Shared building block for c2p/p2c: dots per-head content vectors against a
130
+ small per-category table, then gathers by category index. Cheaper than
131
+ materializing a full [B,H,S,S,d_k] tensor since there are only a handful
132
+ of relation categories.
133
+
134
+ content: [B, H, S, d_k] query content for c2p (indexed by row/query pos),
135
+ key content for p2c (indexed by col/key pos)
136
+ rel_vecs: [H, n_cat, d_k] category vectors, already head-projected
137
+ cats: [B, S, S] category index per (row=query, col=key) pair
138
+ content_is_col: False for c2p (content indexed by row i, broadcast over col j),
139
+ True for p2c (content indexed by col j, broadcast over row i)
140
+ returns: [B, H, S, S]
141
+ """
142
+ B, H, S, _ = content.shape
143
+ n_cat = rel_vecs.shape[1]
144
+ per_cat = torch.einsum('bhsd,hcd->bhsc', content, rel_vecs) # [B, H, S, n_cat]
145
+ if content_is_col:
146
+ per_cat = per_cat.unsqueeze(2).expand(B, H, S, S, n_cat) # [B, H, S(row), S(col=s), n_cat]
147
+ else:
148
+ per_cat = per_cat.unsqueeze(3).expand(B, H, S, S, n_cat) # [B, H, S(row=s), S(col), n_cat]
149
+ idx = cats.unsqueeze(1).unsqueeze(-1).expand(B, H, S, S, 1)
150
+ return torch.gather(per_cat, -1, idx).squeeze(-1)
151
+
152
+
153
+ class GenomicSelfAttention(nn.Module):
154
+ """
155
+ Multi-head self-attention with two DeBERTa-style disentangled bias channels
156
+ (strand/relation and distance), computed once per forward pass at the
157
+ model level and threaded through every layer:
158
+
159
+ scores = QK/sqrt(d_k)
160
+ + sigmoid(gate_c2p_strand) * c2p_strand
161
+ + sigmoid(gate_p2c_strand) * p2c_strand (DeBERTa strand/relation)
162
+ + sigmoid(gate_c2p_pos) * c2p_pos
163
+ + sigmoid(gate_p2c_pos) * p2c_pos (DeBERTa distance)
164
+
165
+ No plain T5-style scalar-per-bucket scaffold: it reliably won the gradient
166
+ race over c2p_pos/p2c_pos, starving the content-gated channel of pressure
167
+ to learn real content-dependent distance expectations (e.g. a portal
168
+ protein expecting a head-scaffolding protein at a fixed offset, which a
169
+ different protein type at the same offset shouldn't care about).
170
+
171
+ c2p is query-content-gated, p2c is key-content-gated -- see RelationalBias
172
+ for why the category embeddings are projected through this layer's own
173
+ q_proj/k_proj rather than being a standalone lookup.
174
+ """
175
+
176
+ def __init__(self, d_model: int, num_heads: int, dropout: float = 0.0):
177
+ super().__init__()
178
+ assert d_model % num_heads == 0
179
+ self.num_heads = num_heads
180
+ self.d_k = d_model // num_heads
181
+
182
+ self.q_proj = nn.Linear(d_model, d_model, bias=False)
183
+ self.k_proj = nn.Linear(d_model, d_model, bias=False)
184
+ self.v_proj = nn.Linear(d_model, d_model, bias=False)
185
+ self.out_proj = nn.Linear(d_model, d_model)
186
+ self.bias_drop = nn.Dropout(dropout)
187
+
188
+ # Diagnostic-only: when True, forward() records per-head realized-influence
189
+ # stats (gate * bias term, masked-mean-abs) for _log_bias_diagnostics to read.
190
+ # Left False during normal training so the hot path (and torch.compile) never
191
+ # sees this branch; a dedicated uncompiled diagnostic forward flips it on.
192
+ self.record_influence = False
193
+ self.last_influence_strand = None # set to a detached [H] tensor when recording
194
+ self.last_influence_pos = None
195
+
196
+ def forward(self, x, mask, external_bias, output_attentions=False):
197
+ if external_bias is None:
198
+ raise ValueError("external_bias dict is required.")
199
+
200
+ B, S, D = x.shape
201
+ amp_dtype = x.dtype
202
+
203
+ pos_buckets = external_bias["pos_buckets"] # [B, S, S], long bucket index
204
+ orient_c = external_bias["orient_cats"] # [B, S, S], long strand-category index
205
+ strand_k = external_bias["strand_key_embed"] # [n_strand_cat, D], raw (unprojected)
206
+ strand_q = external_bias["strand_query_embed"] # [n_strand_cat, D], raw (unprojected)
207
+ pos_k = external_bias["pos_key_embed"] # [n_pos_buckets, D], raw (unprojected)
208
+ pos_q = external_bias["pos_query_embed"] # [n_pos_buckets, D], raw (unprojected)
209
+ gate_c2p_s = external_bias["rel_gate_c2p_strand"] # [H]
210
+ gate_p2c_s = external_bias["rel_gate_p2c_strand"] # [H]
211
+ gate_c2p_p = external_bias["rel_gate_c2p_pos"] # [H]
212
+ gate_p2c_p = external_bias["rel_gate_p2c_pos"] # [H]
213
+
214
+ def split_heads(t):
215
+ return t.reshape(B, S, self.num_heads, self.d_k).transpose(1, 2)
216
+
217
+ q = split_heads(self.q_proj(x))
218
+ k = split_heads(self.k_proj(x))
219
+ v = split_heads(self.v_proj(x))
220
+
221
+ content_scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k)
222
+
223
+ def split_heads_cat(t, n_cat):
224
+ # t: [n_cat, D] -> [H, n_cat, d_k], independent of the sequence-level B/S.
225
+ return t.reshape(n_cat, self.num_heads, self.d_k).transpose(0, 1)
226
+
227
+ n_strand_cat = strand_k.shape[0]
228
+ k_rel_s = split_heads_cat(self.k_proj(strand_k), n_strand_cat) # [H, n_strand_cat, d_k]
229
+ q_rel_s = split_heads_cat(self.q_proj(strand_q), n_strand_cat) # [H, n_strand_cat, d_k]
230
+
231
+ n_pos_cat = pos_k.shape[0]
232
+ k_rel_p = split_heads_cat(self.k_proj(pos_k), n_pos_cat) # [H, n_pos_buckets, d_k]
233
+ q_rel_p = split_heads_cat(self.q_proj(pos_q), n_pos_cat) # [H, n_pos_buckets, d_k]
234
+
235
+ c2p_strand = _relation_scores(q, k_rel_s, orient_c, content_is_col=False) / math.sqrt(self.d_k)
236
+ p2c_strand = _relation_scores(k, q_rel_s, orient_c, content_is_col=True) / math.sqrt(self.d_k)
237
+ c2p_pos = _relation_scores(q, k_rel_p, pos_buckets, content_is_col=False) / math.sqrt(self.d_k)
238
+ p2c_pos = _relation_scores(k, q_rel_p, pos_buckets, content_is_col=True) / math.sqrt(self.d_k)
239
+
240
+ rel_bias_strand = (
241
+ gate_c2p_s.sigmoid().view(1, -1, 1, 1) * c2p_strand +
242
+ gate_p2c_s.sigmoid().view(1, -1, 1, 1) * p2c_strand
243
+ )
244
+ rel_bias_pos = (
245
+ gate_c2p_p.sigmoid().view(1, -1, 1, 1) * c2p_pos +
246
+ gate_p2c_p.sigmoid().view(1, -1, 1, 1) * p2c_pos
247
+ )
248
+ rel_bias = rel_bias_strand + rel_bias_pos
249
+
250
+ scores = content_scores + self.bias_drop(rel_bias.to(amp_dtype))
251
+
252
+ mask_bool = mask.bool()
253
+ key_invalid = ~mask_bool.unsqueeze(1).unsqueeze(2)
254
+ scores = scores.masked_fill(key_invalid, torch.finfo(amp_dtype).min)
255
+
256
+ if self.record_influence:
257
+ # Realized per-head contribution: gate * bias term, masked-mean-abs over
258
+ # valid key positions. This is the "what it actually does" figure, as
259
+ # opposed to the gate alone ("what it wants") — not equivalent to
260
+ # gate-mean * term-norm-mean since both vary per head.
261
+ with torch.no_grad():
262
+ valid_key = mask_bool.to(rel_bias_strand.dtype).unsqueeze(1).unsqueeze(2) # [B,1,1,S]
263
+ denom = valid_key.sum().clamp(min=1)
264
+ self.last_influence_strand = (rel_bias_strand.detach().abs() * valid_key).sum(dim=(0, 2, 3)) / denom
265
+ self.last_influence_pos = (rel_bias_pos.detach().abs() * valid_key).sum(dim=(0, 2, 3)) / denom
266
+
267
+ attn = F.softmax(scores, dim=-1)
268
+ out = torch.matmul(attn, v)
269
+ out = out * mask_bool.unsqueeze(1).unsqueeze(-1)
270
+ out = out.transpose(1, 2).reshape(B, S, D)
271
+
272
+ # No separate diagnostic softmax needed: scores are already just
273
+ # content_scores + rel_bias (no T5 scalar to isolate against), so
274
+ # attn itself is the "content+relational" view.
275
+ return self.out_proj(out), (attn if output_attentions else None)
phlam/model.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+ from phlam.attention import (
5
+ GenomicSelfAttention, GenomicDistanceBucketeer,
6
+ N_POS_BUCKETS, N_STRAND_CATEGORIES,
7
+ compute_genomic_bias_indices,
8
+ )
9
+
10
+ class ProjectionHead(nn.Module):
11
+ """
12
+ Semi-orthogonal (Stiefel-constrained) linear map: guarantees
13
+ ||proj(x) - proj(y)|| == ||x - y|| exactly, for any pair, by
14
+ construction -- not encouraged by a loss, structurally true regardless
15
+ of batch composition. When d_model > d_in this leaves d_model - d_in
16
+ output dimensions exactly zero at the projection itself; downstream
17
+ attention/FFN layers are unconstrained and free to populate them,
18
+ keeping content preservation and context integration as separate jobs
19
+ instead of one layer having to trade off between them.
20
+ """
21
+ def __init__(self, d_in: int, d_model: int):
22
+ super().__init__()
23
+ self.net = nn.Linear(d_in, d_model, bias=False)
24
+ #nn.utils.parametrizations.orthogonal(self.net, name="weight")
25
+ def forward(self, x): return self.net(x)
26
+
27
+ class ProteomeReconHead(nn.Module):
28
+ def __init__(self, d_model: int, d_out: int):
29
+ super().__init__()
30
+ self.net = nn.Sequential(
31
+ nn.Linear(d_model, d_model), nn.ELU(),
32
+ nn.Linear(d_model, d_out),
33
+ )
34
+ def forward(self, x): return self.net(x)
35
+
36
+
37
+ class PhyloInitializer(nn.Module):
38
+ """Cross-attention seeding the CLS token from the protein sequence."""
39
+ def __init__(self, d_model: int):
40
+ super().__init__()
41
+ self.q = nn.Linear(d_model, d_model, bias=False)
42
+ self.k = nn.Linear(d_model, d_model, bias=False)
43
+ self.v = nn.Linear(d_model, d_model, bias=False)
44
+ self.scale = d_model ** -0.5
45
+ self.ln = nn.LayerNorm(d_model)
46
+
47
+ def forward(self, phylo_token, h_p, mask):
48
+ q = self.q(phylo_token)
49
+ k = self.k(h_p)
50
+ v = self.v(h_p)
51
+ attn = torch.matmul(q, k.transpose(-1, -2)) * self.scale
52
+ attn = attn.masked_fill(~mask.bool().unsqueeze(1), torch.finfo(attn.dtype).min)
53
+ return self.ln(phylo_token + torch.matmul(F.softmax(attn, dim=-1), v))
54
+
55
+
56
+ class StrandPredHead(nn.Module):
57
+ """Predicts whether this position's fed-in strand_id was flipped (0=native, 1=flipped)."""
58
+ def __init__(self, d_model: int):
59
+ super().__init__()
60
+ self.norm = nn.LayerNorm(d_model)
61
+ self.linear = nn.Linear(d_model, 2)
62
+
63
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
64
+ return self.linear(self.norm(x))
65
+
66
+
67
+
68
+ class RelationalBias(nn.Module):
69
+ """
70
+ DeBERTa-style disentangled bias table, shared shape for both channels that
71
+ use it: strand/relation (categories = same/convergent/divergent/opp_far/
72
+ global/sink) and distance (categories = signed position buckets). Holds one
73
+ d_model vector per category for each of the c2p (query-gated) and p2c
74
+ (key-gated) channels. Each attention layer projects these through its own
75
+ q_proj/k_proj so the vectors are entangled with content rather than being
76
+ a content-free shortcut — the actual per-head dot products happen inside
77
+ GenomicSelfAttention.
78
+ """
79
+ def __init__(self, n_categories: int, d_model: int):
80
+ super().__init__()
81
+ self.key_embed = nn.Embedding(n_categories, d_model) # dotted against queries (c2p)
82
+ self.query_embed = nn.Embedding(n_categories, d_model) # dotted against keys (p2c)
83
+ # Small init (std=0.002) plus the -2.0 gate init
84
+ # means the relational term starts near-silent and only grows once content
85
+ # attention is stable, per the "gates fade in after content stabilizes" design.
86
+ nn.init.normal_(self.key_embed.weight, std=0.002)
87
+ nn.init.normal_(self.query_embed.weight, std=0.002)
88
+
89
+ def forward(self):
90
+ return self.key_embed.weight, self.query_embed.weight
91
+
92
+
93
+ class GenomicTransformerBlock(nn.Module):
94
+ def __init__(self, d_model: int, num_heads: int, dropout: float = 0.2):
95
+ super().__init__()
96
+ self.attn = GenomicSelfAttention(d_model=d_model, num_heads=num_heads, dropout=dropout)
97
+ self.ln1 = nn.LayerNorm(d_model)
98
+ self.ln2 = nn.LayerNorm(d_model)
99
+ self.ff = nn.Sequential(
100
+ nn.Linear(d_model, 4 * d_model), nn.GELU(),
101
+ nn.Linear(4 * d_model, d_model), nn.Dropout(dropout),
102
+ )
103
+
104
+ def forward(self, x, mask, output_attentions=False, external_bias=None):
105
+ res, weights = self.attn(self.ln1(x), mask, external_bias=external_bias, output_attentions=output_attentions)
106
+ x = x + res
107
+ x = x + self.ff(self.ln2(x))
108
+ x = x * mask.bool().unsqueeze(-1) # prevent FFN bias accumulation at padding slots
109
+ return x, weights
110
+
111
+
112
+ class PHLAM(nn.Module):
113
+ """
114
+ Genomic LM for phage proteomes with a strand-flip-detection auxiliary task.
115
+
116
+ Strand IDs: 0=padding, 1=fwd, 2=rev, 3=CLS. The trainer may feed a corrupted
117
+ strand_id (1<->2 flipped) at a random subset of positions; the model has no
118
+ way to tell from strand_id alone, so strand_pred_head must catch the flip
119
+ from context (local synteny/orientation grammar), not from the label itself.
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ input_dim: int = 480,
125
+ d_model: int = 512,
126
+ output_dim: int = 480,
127
+ num_global: int = 1,
128
+ num_layers: int = 6,
129
+ num_heads: int = 8,
130
+ max_dist: int = 300,
131
+ opp_far_dist: float = 16.0,
132
+ dropout: float = 0.2,
133
+ ):
134
+ super().__init__()
135
+ self.G = num_global
136
+ self.num_heads = num_heads
137
+
138
+ # Both fixed, not learnable: a trainable placeholder has a direct
139
+ # gradient path to collapse toward the conditional mean of the target
140
+ # distribution instead of forcing genuine context-based inference.
141
+ # esm_mask_token is input_dim space (gene-dropout augmentation, see
142
+ # trainer.py's augment()); esm_bert_mask is d_model space, post-
143
+ # projection (BERT-style corruption_mask substitution below).
144
+ self.register_buffer("esm_mask_token", torch.zeros(1, 1, input_dim))
145
+ self.register_buffer("esm_bert_mask", torch.zeros(1, 1, d_model))
146
+
147
+ # Skip the projection when dims match: identity preserves ESM's own
148
+ # basis exactly, not just its geometry -- the strongest possible
149
+ # guarantee against losing what ESM learned.
150
+ self.projection = nn.Identity() if input_dim == d_model else ProjectionHead(input_dim, d_model)
151
+ self.reconstruct_esm = ProteomeReconHead(d_model, output_dim)
152
+ self.strand_pred_head = StrandPredHead(d_model)
153
+ self.cls_token = nn.Parameter(torch.randn(1, num_global, d_model) * 0.02)
154
+ self.phylo_init = PhyloInitializer(d_model=d_model)
155
+
156
+ self.layers = nn.ModuleList([
157
+ GenomicTransformerBlock(d_model=d_model, num_heads=num_heads, dropout=dropout)
158
+ for _ in range(num_layers)
159
+ ])
160
+
161
+ self.bucketeer = GenomicDistanceBucketeer(n_bins=32, max_dist=max_dist)
162
+ self.opp_far_dist = opp_far_dist
163
+ self.strand_bias = RelationalBias(N_STRAND_CATEGORIES, d_model)
164
+ # No plain T5-style scalar scaffold: it reliably won the gradient race
165
+ # over c2p_pos/p2c_pos (pos_layer_scale saturating almost everywhere),
166
+ # starving the content-gated channel of pressure to learn real
167
+ # content-dependent distance expectations.
168
+ self.pos_rel_bias = RelationalBias(N_POS_BUCKETS, d_model)
169
+
170
+ # Per-layer, per-head sigmoid gates, split by direction (c2p = query-
171
+ # driven, p2c = key-driven) and channel (strand vs. distance) so each
172
+ # can fade in independently. Init negative so gates open only after
173
+ # content attention stabilizes.
174
+ self.rel_gate_c2p_strand = nn.Parameter(torch.full((num_layers, num_heads), -2.0))
175
+ self.rel_gate_p2c_strand = nn.Parameter(torch.full((num_layers, num_heads), -2.0))
176
+ self.rel_gate_c2p_pos = nn.Parameter(torch.full((num_layers, num_heads), -2.0))
177
+ self.rel_gate_p2c_pos = nn.Parameter(torch.full((num_layers, num_heads), -2.0))
178
+
179
+ def forward(
180
+ self,
181
+ strand_ids: torch.Tensor,
182
+ esm_embeds: torch.Tensor,
183
+ is_circular: float = 1.0,
184
+ output_attentions: bool = False,
185
+ corruption_mask: torch.Tensor | None = None,
186
+ ) -> dict:
187
+ B, L, _ = esm_embeds.shape
188
+ device = esm_embeds.device
189
+
190
+ is_circular_tensor = torch.as_tensor(is_circular, device=device, dtype=torch.float32)
191
+ protein_mask = (strand_ids == 1) | (strand_ids == 2)
192
+
193
+ # Project in float32: ESM embeddings are compact unit-sphere vectors (~0.05/dim)
194
+ # where bf16's 7-bit mantissa loses meaningful precision. Transformer layers
195
+ # downstream run in bf16 via the outer autocast context.
196
+ with torch.amp.autocast(device_type=device.type, enabled=False):
197
+ x = self.projection(esm_embeds.float()) # [B, L, d_model] float32
198
+ projected = x # clean, pre-corruption, pre-transformer -- returned for representation-depth analysis
199
+
200
+ # Apply BERT mask token before seeding CLS so CLS never sees clean
201
+ # content of masked positions — avoids a shortcut where masked tokens
202
+ # can attend to CLS and recover their original embedding.
203
+ # Without a corruption_mask (e.g. view-2, ae-mode) x stays clean.
204
+ if corruption_mask is not None:
205
+ x = torch.where(
206
+ corruption_mask.unsqueeze(-1).expand_as(x),
207
+ self.esm_bert_mask.expand(B, L, -1),
208
+ x,
209
+ )
210
+
211
+ # Seed CLS from (possibly corrupted) projections.
212
+ # At 15% masking 85% of proteins are still visible — enough signal
213
+ # for a good phylogenetic summary without leaking masked content.
214
+ cls = self.phylo_init(
215
+ self.cls_token.expand(B, -1, -1), x, mask=protein_mask
216
+ )
217
+
218
+ x_combined = torch.cat([cls, x], dim=1)
219
+ combined_mask = torch.cat([
220
+ torch.ones(B, self.G, dtype=torch.bool, device=device),
221
+ protein_mask,
222
+ ], dim=1)
223
+ strands_plus = torch.cat([
224
+ torch.full((B, self.G), 3, dtype=torch.long, device=device),
225
+ strand_ids.long(),
226
+ ], dim=1)
227
+
228
+ # Bucket/category indices computed once; shared across all layers.
229
+ pos_buckets, orient_cats = compute_genomic_bias_indices(
230
+ strands_plus, combined_mask, is_circular_tensor, self.bucketeer,
231
+ opp_far_dist=self.opp_far_dist,
232
+ )
233
+ strand_key_embed, strand_query_embed = self.strand_bias() # each [n_strand_cat, D]
234
+ pos_key_embed, pos_query_embed = self.pos_rel_bias() # each [n_pos_buckets, D]
235
+
236
+ all_attentions = []
237
+ for li, layer in enumerate(self.layers):
238
+ x_combined, layer_attn = layer(
239
+ x_combined, combined_mask,
240
+ output_attentions=output_attentions,
241
+ external_bias={
242
+ "pos_buckets": pos_buckets,
243
+ "orient_cats": orient_cats,
244
+ "strand_key_embed": strand_key_embed,
245
+ "strand_query_embed": strand_query_embed,
246
+ "pos_key_embed": pos_key_embed,
247
+ "pos_query_embed": pos_query_embed,
248
+ "rel_gate_c2p_strand": self.rel_gate_c2p_strand[li],
249
+ "rel_gate_p2c_strand": self.rel_gate_p2c_strand[li],
250
+ "rel_gate_c2p_pos": self.rel_gate_c2p_pos[li],
251
+ "rel_gate_p2c_pos": self.rel_gate_p2c_pos[li],
252
+ },
253
+ )
254
+ if output_attentions and layer_attn is not None:
255
+ all_attentions.append(layer_attn)
256
+
257
+ h_global_tokens = x_combined[:, :self.G, :]
258
+ h_protein = x_combined[:, self.G:, :]
259
+ h_global = h_global_tokens.squeeze(1) if self.G == 1 else h_global_tokens.mean(1)
260
+ recon_esm = self.reconstruct_esm(h_protein)
261
+ # h_protein only, not h_protein+h_global: h_global is already derived
262
+ # from h_protein, and every position already reaches genome-level
263
+ # context via CLS through ordinary self-attention across layers --
264
+ # adding the same h_global vector to every position again here would
265
+ # be a redundant, per-genome-constant signal that StrandPredHead's
266
+ # LayerNorm would partly normalize away regardless.
267
+ strand_logits = self.strand_pred_head(h_protein)
268
+
269
+ return {
270
+ "recon_esm": recon_esm,
271
+ "strand_logits": strand_logits,
272
+ "h_protein": h_protein,
273
+ "h_global": h_global,
274
+ "projected": projected,
275
+ "attentions": tuple(all_attentions) if output_attentions else None,
276
+ "mask": protein_mask,
277
+ }
pipeline.py CHANGED
@@ -11,12 +11,14 @@ from utils.gene_caller import call_genes
11
  from utils.esm_embedder import ESM2Embedder
12
  from utils.ncbi_retrieval import fetch_ncbi_accession
13
  from utils.annotation_retrieval import AnnotationRetriever
 
14
 
15
  ESM_MODEL_NAME = os.environ.get("ESM_MODEL", "facebook/esm2_t12_35M_UR50D")
16
  MIN_GENE_LEN = int(os.environ.get("MIN_GENE_LEN", "60"))
17
  ACCESSION_MAX_LEN = 12
18
 
19
  _esm_embedder = None
 
20
  _annotation_retriever = None
21
 
22
 
@@ -27,6 +29,13 @@ def _get_esm():
27
  return _esm_embedder
28
 
29
 
 
 
 
 
 
 
 
30
  def _get_annotation_retriever():
31
  # Not cached on failure -- the mounted bucket may not be attached yet.
32
  global _annotation_retriever
@@ -87,6 +96,9 @@ def run_pipeline(raw_input: str, is_circular: bool = True) -> dict:
87
  protein_seqs = df["sequence"].tolist()
88
  esm_embeds = _get_esm().get_embeddings(protein_seqs).numpy()
89
 
 
 
 
90
  try:
91
  retriever = _get_annotation_retriever()
92
  annotations = retriever.query(esm_embeds)
@@ -95,7 +107,7 @@ def run_pipeline(raw_input: str, is_circular: bool = True) -> dict:
95
  annotations = [{"top_hit": None, "other_source_hit": None}] * len(df)
96
 
97
  gene_records = []
98
- for row, annotation in zip(df.to_dict(orient="records"), annotations):
99
  gene_records.append({
100
  "id": row["readable_gene_id"],
101
  "order": row["order"],
@@ -108,6 +120,7 @@ def run_pipeline(raw_input: str, is_circular: bool = True) -> dict:
108
  "length_bp": row["length_bp"],
109
  "sequence": row["sequence"],
110
  "annotation": annotation,
 
111
  })
112
 
113
  return {
@@ -116,4 +129,9 @@ def run_pipeline(raw_input: str, is_circular: bool = True) -> dict:
116
  "genome_length": len(sequence),
117
  "num_genes": len(gene_records),
118
  "genes": gene_records,
 
 
 
 
 
119
  }
 
11
  from utils.esm_embedder import ESM2Embedder
12
  from utils.ncbi_retrieval import fetch_ncbi_accession
13
  from utils.annotation_retrieval import AnnotationRetriever
14
+ from utils.phlam_embedder import PHLAMEmbedder
15
 
16
  ESM_MODEL_NAME = os.environ.get("ESM_MODEL", "facebook/esm2_t12_35M_UR50D")
17
  MIN_GENE_LEN = int(os.environ.get("MIN_GENE_LEN", "60"))
18
  ACCESSION_MAX_LEN = 12
19
 
20
  _esm_embedder = None
21
+ _phlam_embedder = None
22
  _annotation_retriever = None
23
 
24
 
 
29
  return _esm_embedder
30
 
31
 
32
+ def _get_phlam():
33
+ global _phlam_embedder
34
+ if _phlam_embedder is None:
35
+ _phlam_embedder = PHLAMEmbedder(device="cpu")
36
+ return _phlam_embedder
37
+
38
+
39
  def _get_annotation_retriever():
40
  # Not cached on failure -- the mounted bucket may not be attached yet.
41
  global _annotation_retriever
 
96
  protein_seqs = df["sequence"].tolist()
97
  esm_embeds = _get_esm().get_embeddings(protein_seqs).numpy()
98
 
99
+ phlam = _get_phlam().embed(esm_embeds, df["strand"].tolist(), is_circular=is_circular)
100
+ phlam_protein = phlam["phlam_protein"] # [L, 480], one row per gene, same order as df
101
+
102
  try:
103
  retriever = _get_annotation_retriever()
104
  annotations = retriever.query(esm_embeds)
 
107
  annotations = [{"top_hit": None, "other_source_hit": None}] * len(df)
108
 
109
  gene_records = []
110
+ for row, annotation, protein_vec in zip(df.to_dict(orient="records"), annotations, phlam_protein):
111
  gene_records.append({
112
  "id": row["readable_gene_id"],
113
  "order": row["order"],
 
120
  "length_bp": row["length_bp"],
121
  "sequence": row["sequence"],
122
  "annotation": annotation,
123
+ "phlam_protein": protein_vec.tolist(),
124
  })
125
 
126
  return {
 
129
  "genome_length": len(sequence),
130
  "num_genes": len(gene_records),
131
  "genes": gene_records,
132
+ "phlam_genome": phlam["phlam_genome"].tolist(),
133
+ # not for display -- raw arrays for app.py to reuse starting the
134
+ # neighbor-search background jobs without recomputing embeddings.
135
+ "esm_embeds": esm_embeds,
136
+ "phlam_protein": phlam_protein,
137
  }
requirements.txt CHANGED
@@ -8,3 +8,4 @@ pyrodigal>=3.0
8
  torch
9
  transformers>=4.40
10
  faiss-cpu>=1.8
 
 
8
  torch
9
  transformers>=4.40
10
  faiss-cpu>=1.8
11
+ duckdb>=1.0
static/circular-viewer.css ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* PHLAM circular genome viewer — scoped variables + tooltip.
2
+ Prefixed --phlam-* custom properties avoid clashing with the host
3
+ Gradio page's own theme variables (this file is loaded inside the
4
+ viewer's own iframe document, but kept prefixed regardless). */
5
+ :root {
6
+ --phlam-bg: #FCFBF9;
7
+ --phlam-surface: #F2F0EC;
8
+ --phlam-muted: #5C584F;
9
+ --phlam-accent: oklch(0.48 0.11 195);
10
+ --phlam-fwd: oklch(0.48 0.11 195);
11
+ --phlam-rev: oklch(0.5 0.13 25);
12
+ --phlam-warn: oklch(0.55 0.13 75);
13
+ --phlam-pass: oklch(0.48 0.11 195);
14
+ --phlam-text: #191A19;
15
+ --phlam-border: #D6D0C4;
16
+ }
17
+
18
+ [data-phlam-theme="dark"] {
19
+ --phlam-bg: #12151a;
20
+ --phlam-surface: #1c2129;
21
+ --phlam-muted: #7d8794;
22
+ --phlam-accent: #5ec8f8;
23
+ --phlam-fwd: #55c58a;
24
+ --phlam-rev: #e0736b;
25
+ --phlam-warn: #e0b04b;
26
+ --phlam-pass: #55c58a;
27
+ --phlam-text: #e7ebf0;
28
+ --phlam-border: #2c333d;
29
+ }
30
+
31
+ .phlam-gv-outer {
32
+ display: flex;
33
+ align-items: flex-start;
34
+ gap: 12px;
35
+ }
36
+
37
+ .phlam-gv-wrap {
38
+ display: flex;
39
+ justify-content: center;
40
+ background: var(--phlam-bg);
41
+ border: 1px solid var(--phlam-border);
42
+ border-radius: 8px;
43
+ padding: 8px;
44
+ flex: 1 1 auto;
45
+ min-width: 0; /* allow the flex item to shrink below the SVG's intrinsic width */
46
+ }
47
+
48
+ .phlam-gv-wrap svg {
49
+ max-width: 100%;
50
+ height: auto;
51
+ }
52
+
53
+ /* Tooltip -- a permanent in-flow side panel next to the chart, at a fixed
54
+ spot (not position:fixed, not following the mouse). Its content swaps on
55
+ hover/pin; it never disappears or resizes the layout. */
56
+ .gv-tooltip {
57
+ flex: 0 0 320px;
58
+ width: 320px;
59
+ align-self: flex-start;
60
+ padding: 12px 14px;
61
+ background: var(--phlam-surface);
62
+ border: 1px solid var(--phlam-border);
63
+ border-radius: 8px;
64
+ color: var(--phlam-text);
65
+ font-family: "Azeret Mono", ui-monospace, monospace;
66
+ font-size: 13.5px;
67
+ line-height: 1.55;
68
+ box-sizing: border-box;
69
+ }
70
+
71
+ .gv-tooltip--pinned {
72
+ border-color: var(--phlam-accent);
73
+ border-width: 1.5px;
74
+ }
75
+
76
+ .gv-tt-placeholder {
77
+ color: var(--phlam-muted);
78
+ font-size: 13px;
79
+ }
80
+
81
+ .gv-tt-title {
82
+ font-weight: 700;
83
+ font-size: 15px;
84
+ margin-bottom: 5px;
85
+ word-break: break-all;
86
+ }
87
+
88
+ .gv-tt-loc {
89
+ color: var(--phlam-muted);
90
+ margin-bottom: 6px;
91
+ }
92
+
93
+ .gv-tt-annot-axis {
94
+ padding: 6px 6px 2px;
95
+ margin-bottom: 8px;
96
+ border: 1px solid var(--phlam-accent);
97
+ border-radius: 6px;
98
+ /* One tier lighter than .gv-tt-annot-item's own --phlam-bg, so the
99
+ category/annotation item boxes still stand out inside this axis box
100
+ instead of blending into it. */
101
+ background: var(--phlam-surface);
102
+ }
103
+
104
+ .gv-tt-annot-axis:last-child {
105
+ margin-bottom: 0;
106
+ }
107
+
108
+ .gv-tt-annot-axis-label {
109
+ display: flex;
110
+ justify-content: space-between;
111
+ align-items: baseline;
112
+ gap: 6px;
113
+ margin: 0 0 4px;
114
+ font-size: 11.5px;
115
+ font-weight: 700;
116
+ text-transform: uppercase;
117
+ letter-spacing: 0.03em;
118
+ color: var(--phlam-accent);
119
+ }
120
+
121
+ .gv-tt-annot-axis-meta {
122
+ font-weight: 500;
123
+ text-transform: none;
124
+ letter-spacing: normal;
125
+ color: var(--phlam-muted);
126
+ font-size: 11px;
127
+ }
128
+
129
+ .gv-tt-annot-conf {
130
+ font-weight: 600;
131
+ }
132
+
133
+ .gv-tt-annot-conf-high {
134
+ color: var(--phlam-pass);
135
+ }
136
+
137
+ .gv-tt-annot-conf-mid {
138
+ color: var(--phlam-warn);
139
+ }
140
+
141
+ .gv-tt-annot-conf-low {
142
+ color: var(--phlam-rev);
143
+ }
144
+
145
+ .gv-tt-annot-group {
146
+ margin-bottom: 8px;
147
+ }
148
+
149
+ .gv-tt-annot-item {
150
+ padding: 4px 7px;
151
+ margin-bottom: 4px;
152
+ background: var(--phlam-bg);
153
+ border: 1px solid var(--phlam-border);
154
+ border-radius: 5px;
155
+ }
156
+
157
+ .gv-tt-annot-item:last-child {
158
+ margin-bottom: 0;
159
+ }
160
+
161
+ .gv-tt-annot-item.gv-tt-annot-unresolved {
162
+ opacity: 0.55;
163
+ }
164
+
165
+ .gv-tt-annot-item.gv-tt-annot-unresolved .gv-tt-annot-text {
166
+ font-style: italic;
167
+ }
168
+
169
+ .gv-tt-annot-level {
170
+ font-size: 11px;
171
+ text-transform: uppercase;
172
+ letter-spacing: 0.03em;
173
+ color: var(--phlam-muted);
174
+ margin-bottom: 2px;
175
+ }
176
+
177
+ .gv-tt-annot-text {
178
+ display: flex;
179
+ justify-content: space-between;
180
+ align-items: baseline;
181
+ gap: 8px;
182
+ font-size: 13.5px;
183
+ }
184
+
185
+ .gv-tt-annot-sim {
186
+ flex: 0 0 auto;
187
+ color: var(--phlam-muted);
188
+ font-size: 11.5px;
189
+ }
190
+
191
+ .gv-tt-row {
192
+ display: flex;
193
+ justify-content: space-between;
194
+ gap: 10px;
195
+ color: var(--phlam-text);
196
+ }
197
+
198
+ .gv-tt-row span:first-child {
199
+ color: var(--phlam-muted);
200
+ }
201
+
202
+ .gv-tt-pin {
203
+ margin-top: 8px;
204
+ padding-top: 6px;
205
+ border-top: 1px dashed var(--phlam-border);
206
+ color: var(--phlam-muted);
207
+ font-size: 11.5px;
208
+ }
static/circular_genome_viewer.js ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared with static/genome-viewer.js's PHLAM_PALETTE/phlamHashColor -- same
2
+ // palette + same djb2 hash-of-the-annotation-string means a given annotation
3
+ // (e.g. "endolysin") gets the identical color in both the circular view and
4
+ // the synteny view, since neither view's local set of categories affects it
5
+ // (unlike an ordinal scale keyed to sort- or appearance-order, which would
6
+ // reassign colors whenever the surrounding category set changes).
7
+ const PHLAM_PALETTE = [
8
+ 'oklch(0.62 0.11 195)', 'oklch(0.62 0.11 25)', 'oklch(0.55 0.09 260)',
9
+ 'oklch(0.62 0.11 140)', 'oklch(0.65 0.13 60)', 'oklch(0.55 0.10 320)',
10
+ 'oklch(0.62 0.11 90)', 'oklch(0.58 0.12 350)', 'oklch(0.6 0.08 220)',
11
+ 'oklch(0.65 0.12 10)', 'oklch(0.6 0.1 170)', 'oklch(0.55 0.11 290)',
12
+ 'oklch(0.68 0.1 40)', 'oklch(0.58 0.09 240)', 'oklch(0.6 0.12 110)',
13
+ 'oklch(0.5 0.08 200)',
14
+ ];
15
+ const PHLAM_UNANNOTATED_COLOR = '#C9C4BB';
16
+
17
+ function phlamHashColor(str, palette = PHLAM_PALETTE) {
18
+ if (!str || str === 'unannotated' || str === 'no annotation') return PHLAM_UNANNOTATED_COLOR;
19
+ let h = 5381;
20
+ for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
21
+ return palette[Math.abs(h) % palette.length];
22
+ }
23
+
24
+ class CircularGenomeViewer {
25
+ constructor(svgId, options = {}) {
26
+ this.svgId = svgId;
27
+ this.W = options.width || 700;
28
+ this.H = options.height || 700;
29
+ this.R = options.radius || 260;
30
+ this.rH = options.trackWidth || 16;
31
+ this.HEAD_RAD = 0.075;
32
+ this.globalAttentionData = null;
33
+
34
+ // Interaction state
35
+ this._pinnedGeneIdx = null;
36
+ this._clickTimer = null;
37
+ this._lastClickedIdx = null;
38
+ this._geneNodes = [];
39
+
40
+ this.svg = d3.select(`#${this.svgId}`).attr('width', this.W).attr('height', this.H);
41
+ this.g = this.svg.append('g').attr('transform', `translate(${this.W / 2},${this.H / 2})`);
42
+
43
+ const defs = this.svg.append('defs');
44
+ defs.append('marker')
45
+ .attr('id', 'arrowhead')
46
+ .attr('markerWidth', 10)
47
+ .attr('markerHeight', 10)
48
+ .attr('refX', 8)
49
+ .attr('refY', 3)
50
+ .attr('orient', 'auto')
51
+ .append('polygon')
52
+ .attr('points', '0 0, 10 3, 0 6')
53
+ .attr('fill', 'var(--phlam-accent)');
54
+
55
+ this.chordG = this.g.append('g').attr('class', 'chord-layer');
56
+ this.axisG = this.g.append('g').attr('class', 'axis-layer');
57
+ this.geneG = this.g.append('g').attr('class', 'gene-layer');
58
+ this.labelG = this.g.append('g').attr('class', 'label-layer');
59
+ this.legendG = this.g.append('g').attr('class', 'legend-layer');
60
+
61
+ this._initTooltip();
62
+
63
+ // Click on SVG background unpins
64
+ const self = this;
65
+ this.svg.on('click', () => {
66
+ if (self._pinnedGeneIdx !== null) {
67
+ const node = self._geneNodes[self._pinnedGeneIdx];
68
+ if (node) d3.select(node).attr('fill-opacity', 0.85);
69
+ self._pinnedGeneIdx = null;
70
+ self._hideTooltip();
71
+ self._restoreChords();
72
+ }
73
+ });
74
+
75
+ this.genomeData = null;
76
+ }
77
+
78
+ // ── Tooltip ──────────────────────────────────────────────────────────────
79
+ // A permanent in-flow side panel (not position:fixed) at a fixed spot next
80
+ // to the chart -- never follows the mouse, and never needs viewport-size
81
+ // math (which broke once this chart started living inside a tightly-sized
82
+ // iframe with no guaranteed empty space around it).
83
+
84
+ _initTooltip() {
85
+ if (!document.getElementById('gv-tooltip')) {
86
+ const el = document.createElement('div');
87
+ el.id = 'gv-tooltip';
88
+ el.className = 'gv-tooltip';
89
+ const host = this.svg.node().closest('.phlam-gv-outer') || this.svg.node().parentNode || document.body;
90
+ host.appendChild(el);
91
+ }
92
+ this.tipEl = document.getElementById('gv-tooltip');
93
+ this._placeholderHtml = this.tipEl.innerHTML || '<div class="gv-tt-placeholder">Hover a protein for details.<br>Double-click any protein to copy the full gene table.</div>';
94
+ this._hideTooltip();
95
+ }
96
+
97
+ _showTooltip(html, pinned = false) {
98
+ this.tipEl.innerHTML = html;
99
+ this.tipEl.classList.toggle('gv-tooltip--pinned', pinned);
100
+ this._resizeFrame();
101
+ }
102
+
103
+ _hideTooltip() {
104
+ this.tipEl.innerHTML = this._placeholderHtml;
105
+ this.tipEl.classList.remove('gv-tooltip--pinned');
106
+ this._resizeFrame();
107
+ }
108
+
109
+ // The host <iframe> (viewer.py's srcdoc) starts at a fixed height from
110
+ // Python; the tooltip's content varies a lot per protein (how many
111
+ // annotation axes/homology hits resolved, how long the text wraps), so
112
+ // rather than ever risk this document's own scrollbar appearing, resize
113
+ // the iframe itself to match its actual content height after every
114
+ // change. srcdoc with no sandbox attribute is same-origin, so
115
+ // window.frameElement reaches the real <iframe> node in the parent page.
116
+ //
117
+ // Deliberately measures .phlam-gv-outer (the actual content wrapper), NOT
118
+ // document.documentElement/body: the root element's scrollHeight is
119
+ // max(content height, current viewport height) per spec, and the "current
120
+ // viewport height" of an iframe IS whatever height the parent last set it
121
+ // to -- so measuring the root turns every call into a ratchet that only
122
+ // ever grows (each call's floor is the previous call's result, plus the
123
+ // +4 padding on top of that): +8px on every single hover-in/hover-out
124
+ // pair, forever, never shrinking back down.
125
+ _resizeFrame() {
126
+ if (window.frameElement) {
127
+ const content = this.svg.node().closest('.phlam-gv-outer');
128
+ const h = content ? content.scrollHeight : document.body.scrollHeight;
129
+ window.frameElement.style.height = (h + 4) + 'px';
130
+ }
131
+ }
132
+
133
+ // ── Chord helpers ─────────────────────────────────────────────────────────
134
+
135
+ _dimChordsExcept(i) {
136
+ // Hover: fade non-connected chords to hint
137
+ this.svg.selectAll('.chord').attr('stroke-opacity', 0.03);
138
+ this.svg.selectAll(`.chord-i-${i}`).attr('stroke-opacity', function () {
139
+ return +d3.select(this).attr('data-base-opacity');
140
+ });
141
+ }
142
+
143
+ _isolateChordsFor(i) {
144
+ // Pin: show only ego-network edges (direct ins + outs), hide everything else
145
+ this.svg.selectAll('.chord').attr('stroke-opacity', 0);
146
+ this.svg.selectAll(`.chord-i-${i}`).attr('stroke-opacity', function () {
147
+ return +d3.select(this).attr('data-base-opacity');
148
+ });
149
+ }
150
+
151
+ _restoreChords() {
152
+ this.svg.selectAll('.chord').attr('stroke-opacity', function () {
153
+ return +d3.select(this).attr('data-base-opacity');
154
+ });
155
+ }
156
+
157
+ // ── Clipboard ─────────────────────────────────────────────────────────────
158
+
159
+ _copyGeneFasta(gn) {
160
+ let header = `>${gn.id} # ${gn.start} # ${gn.end} # ${gn.strand}`;
161
+ // PHLAM's only annotation signal is the flat ESM-homology block (gn.homology,
162
+ // built by viewer.py) -- there is no gn.annotation here (that hierarchical
163
+ // category/leaf shape belongs to the sibling genome_view project this
164
+ // viewer was adapted from, and PHLAM never populates it), so the FASTA
165
+ // header must be built from the same fields the hover tooltip already
166
+ // reads at gn.homology.{topHit,otherTopHit,majorityVote}.
167
+ const h = gn.homology;
168
+ if (h) {
169
+ const hit = (key, label) => {
170
+ const entry = h[key];
171
+ if (!entry) return;
172
+ const pct = entry.score != null ? (entry.score * 100).toFixed(0) : '';
173
+ header += ` # ${label}=${entry.text}` + (entry.source ? ` # ${label}_source=${entry.source}` : '')
174
+ + (entry.id ? ` # ${label}_id=${entry.id}` : '')
175
+ + (pct ? ` # ${label}_match=${pct}%` : '');
176
+ };
177
+ hit('topHit', 'top_hit');
178
+ hit('otherTopHit', 'other_top_hit');
179
+ if (h.majorityVote) {
180
+ const frac = h.majorityVote.voteFrac != null ? (h.majorityVote.voteFrac * 100).toFixed(0) : '';
181
+ header += ` # majority_vote=${h.majorityVote.text}` + (h.majorityVote.id ? ` # majority_vote_id=${h.majorityVote.id}` : '')
182
+ + (frac ? ` # majority_vote_agree=${frac}%` : '');
183
+ }
184
+ }
185
+ const text = `${header}\n${gn.sequence || ''}`;
186
+ const flash = (html) => {
187
+ const prev = this.tipEl.innerHTML;
188
+ this.tipEl.innerHTML = html;
189
+ setTimeout(() => { this.tipEl.innerHTML = prev; }, 1400);
190
+ };
191
+ const onCopied = () => flash('<div class="gv-tt-title" style="color:var(--phlam-pass)">✓ Protein FASTA copied</div>');
192
+ const onFailed = () => flash('<div class="gv-tt-title" style="color:var(--phlam-rev)">Copy failed — clipboard unavailable</div>');
193
+ if (navigator.clipboard && navigator.clipboard.writeText) {
194
+ navigator.clipboard.writeText(text).then(onCopied, () => this._legacyCopy(text) ? onCopied() : onFailed());
195
+ } else {
196
+ this._legacyCopy(text) ? onCopied() : onFailed();
197
+ }
198
+ }
199
+
200
+ // Fallback for contexts where the async Clipboard API is unavailable or
201
+ // denied (e.g. some sandboxed/cross-origin iframe embeddings) -- a
202
+ // synchronous execCommand('copy') off a throwaway textarea still works
203
+ // there since it runs inside the same user-gesture (dblclick) call stack.
204
+ _legacyCopy(text) {
205
+ try {
206
+ const ta = document.createElement('textarea');
207
+ ta.value = text;
208
+ ta.style.position = 'fixed';
209
+ ta.style.opacity = '0';
210
+ document.body.appendChild(ta);
211
+ ta.focus();
212
+ ta.select();
213
+ const ok = document.execCommand('copy');
214
+ document.body.removeChild(ta);
215
+ return ok;
216
+ } catch (e) {
217
+ return false;
218
+ }
219
+ }
220
+
221
+ // ── Legend ───────────────────────────────────────────────────────────────
222
+
223
+ _renderLegend(label, lo, hi, colorScale) {
224
+ const w = 150, h = 11, y = this.H / 2 - 36, x = -w / 2;
225
+ const gradId = 'phlam-legend-gradient';
226
+ const defs = this.svg.select('defs');
227
+ defs.select(`#${gradId}`).remove();
228
+ const grad = defs.append('linearGradient').attr('id', gradId).attr('x1', '0%').attr('x2', '100%');
229
+ d3.range(0, 1.0001, 0.1).forEach(t => {
230
+ grad.append('stop').attr('offset', `${t * 100}%`).attr('stop-color', colorScale(lo + t * (hi - lo)));
231
+ });
232
+
233
+ const legend = this.legendG.append('g').attr('transform', `translate(0, ${y})`);
234
+ legend.append('text').attr('x', 0).attr('y', -7).attr('text-anchor', 'middle')
235
+ .attr('fill', 'var(--phlam-muted)').attr('font-size', '11')
236
+ .attr('font-family', "'Azeret Mono', monospace").text(label);
237
+ legend.append('rect').attr('x', x).attr('y', 0).attr('width', w).attr('height', h)
238
+ .attr('fill', `url(#${gradId})`).attr('stroke', 'var(--phlam-border)').attr('stroke-width', 0.75);
239
+ legend.append('text').attr('x', x).attr('y', h + 14).attr('text-anchor', 'start')
240
+ .attr('fill', 'var(--phlam-muted)').attr('font-size', '10.5')
241
+ .attr('font-family', "'Azeret Mono', monospace").text(lo.toFixed(2));
242
+ legend.append('text').attr('x', x + w).attr('y', h + 14).attr('text-anchor', 'end')
243
+ .attr('fill', 'var(--phlam-muted)').attr('font-size', '10.5')
244
+ .attr('font-family', "'Azeret Mono', monospace").text(hi.toFixed(2));
245
+ }
246
+
247
+ // ── Render ────────────────────────────────────────────────────────────────
248
+
249
+ renderGenome(genomeData, colorMetric = null) {
250
+ this.genomeData = genomeData;
251
+ const { genomeSize, genes } = genomeData;
252
+
253
+ // Reset interaction state on each render
254
+ this._pinnedGeneIdx = null;
255
+ this._geneNodes = [];
256
+ if (this._clickTimer) { clearTimeout(this._clickTimer); this._clickTimer = null; }
257
+ this._hideTooltip();
258
+
259
+ this.axisG.selectAll('*').remove();
260
+ this.geneG.selectAll('*').remove();
261
+ this.labelG.selectAll('*').remove();
262
+ this.legendG.selectAll('*').remove();
263
+ this.chordG.selectAll('.global-attn-arrow').remove();
264
+
265
+ if (!genomeSize || !genes) return;
266
+
267
+ const R = this.R, rH = this.rH, HEAD_RAD = this.HEAD_RAD;
268
+ const ang = pos => (pos / genomeSize) * Math.PI * 2 - Math.PI / 2;
269
+ const pt = (r, a) => [Math.cos(a) * r, Math.sin(a) * r];
270
+
271
+ // Strand ('strand' mode, the default) keeps the categorical fwd/rev
272
+ // colors below; any other mode drives fill color from a per-gene metric
273
+ // instead (continuous: sequential scale + gradient legend; categorical,
274
+ // e.g. annotation category/subcategory: ordinal scale + swatch legend)
275
+ // -- gene shape/arrow direction still encodes strand regardless, so no
276
+ // information is lost by repurposing color.
277
+ let colorScale = null;
278
+ if (colorMetric && colorMetric.values && colorMetric.values.length === genes.length) {
279
+ if (colorMetric.type === 'categorical') {
280
+ colorScale = phlamHashColor;
281
+ // No swatch legend here: the categorical case is annotation text,
282
+ // often dozens of distinct values -- genes still get distinct
283
+ // colors per annotation, but a legend listing them all isn't
284
+ // usable. Hover tooltip still shows each gene's own annotation.
285
+ } else {
286
+ const [lo, hi] = colorMetric.domain || d3.extent(colorMetric.values);
287
+ colorScale = d3.scaleSequential(d3.interpolatePlasma).domain([lo, hi === lo ? lo + 1 : hi]);
288
+ this._renderLegend(colorMetric.label, lo, hi, colorScale);
289
+ }
290
+ }
291
+
292
+ // Background ring
293
+ this.axisG.append('circle').attr('r', R).attr('fill', 'none')
294
+ .attr('stroke', 'var(--phlam-surface)').attr('stroke-width', rH * 2 + 6);
295
+
296
+ // Tick marks
297
+ d3.range(8).forEach(i => {
298
+ const a = (i / 8) * Math.PI * 2 - Math.PI / 2;
299
+ const pos = Math.round((i / 8) * genomeSize);
300
+ const lbl = pos >= 1e6 ? (pos / 1e6).toFixed(1) + 'M' : pos >= 1e3 ? (pos / 1e3).toFixed(0) + 'k' : pos;
301
+ const r1 = R + rH + 5, r2 = r1 + 10;
302
+ this.axisG.append('line')
303
+ .attr('x1', Math.cos(a) * r1).attr('y1', Math.sin(a) * r1)
304
+ .attr('x2', Math.cos(a) * r2).attr('y2', Math.sin(a) * r2)
305
+ .attr('stroke', 'var(--phlam-muted)').attr('stroke-width', 1);
306
+ if (i > 0) this.axisG.append('text')
307
+ .attr('x', Math.cos(a) * (r2 + 12)).attr('y', Math.sin(a) * (r2 + 12))
308
+ .attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
309
+ .attr('fill', 'var(--phlam-muted)').attr('font-size', '11')
310
+ .attr('font-family', "'Azeret Mono', monospace").text(lbl);
311
+ });
312
+
313
+ this.geneElements = [];
314
+
315
+ const self = this;
316
+ genes.forEach((gn, i) => {
317
+ const isFwd = gn.strand === '+' || gn.strand === 1;
318
+ let color = gn.color;
319
+ if (!color) {
320
+ if (colorScale) color = colorScale(colorMetric.values[i]);
321
+ else if (gn.partial) color = 'var(--phlam-warn)';
322
+ else color = isFwd ? 'var(--phlam-fwd)' : 'var(--phlam-rev)';
323
+ }
324
+
325
+ const geneRH = rH;
326
+ const gStart = gn.start;
327
+ const angS = ang(gStart), angE = ang(gn.end);
328
+ const span = angE - angS;
329
+ const headA = Math.min(HEAD_RAD, Math.abs(span) * 0.4);
330
+ const bodyEnd = isFwd ? angE - headA : angS + headA;
331
+
332
+ let pathD;
333
+ if (isFwd) {
334
+ const p = [pt(R + geneRH, angS), pt(R + geneRH, bodyEnd), pt(R + geneRH + geneRH * .9, bodyEnd),
335
+ pt(R, angE), pt(R - geneRH - geneRH * .9, bodyEnd), pt(R - geneRH, bodyEnd), pt(R - geneRH, angS)];
336
+ pathD = `M${p[0]} A${R + geneRH},${R + geneRH} 0 0,1 ${p[1]} L${p[2]} L${p[3]} L${p[4]} L${p[5]} A${R - geneRH},${R - geneRH} 0 0,0 ${p[6]} Z`;
337
+ } else {
338
+ const p = [pt(R + geneRH, angE), pt(R + geneRH, bodyEnd), pt(R + geneRH + geneRH * .9, bodyEnd),
339
+ pt(R, angS), pt(R - geneRH - geneRH * .9, bodyEnd), pt(R - geneRH, bodyEnd), pt(R - geneRH, angE)];
340
+ pathD = `M${p[0]} A${R + geneRH},${R + geneRH} 0 0,0 ${p[1]} L${p[2]} L${p[3]} L${p[4]} L${p[5]} A${R - geneRH},${R - geneRH} 0 0,1 ${p[6]} Z`;
341
+ }
342
+
343
+ let tipHtml = `<div class="gv-tt-title" style="color:${color}">${gn.id || 'N/A'}</div>`;
344
+ tipHtml += `<div class="gv-tt-loc">${gStart.toLocaleString()} – ${gn.end.toLocaleString()} bp &nbsp;·&nbsp;<span style="color:${color}">${isFwd ? '(+) Forward' : '(−) Reverse'}</span></div>`;
345
+ if (gn.annotation) {
346
+ // Two independent axes (lifecycle stage / functional role). Within
347
+ // an axis, category/leaf come from the SAME winning pool node (one
348
+ // unified ranking per axis), so they share one similarity and are
349
+ // guaranteed mutually coherent -- no cross-check between them is
350
+ // possible or needed. The two axes are fully independent of each
351
+ // other, each gets its own group + node-type badge.
352
+ for (const axis of Object.values(gn.annotation)) {
353
+ // confidence = fraction of noise-perturbed re-searches that agreed
354
+ // with the winning match -- local stability, not raw similarity.
355
+ const confPct = (axis.confidence * 100).toFixed(0);
356
+ const confCls = axis.confidence >= 0.7 ? ' gv-tt-annot-conf-high' : axis.confidence >= 0.4 ? ' gv-tt-annot-conf-mid' : ' gv-tt-annot-conf-low';
357
+ const meta = (axis.nodeType ? `${axis.nodeType} · ` : '') + `<span class="gv-tt-annot-conf${confCls}">${confPct}% stable</span>`;
358
+ // Each axis is its own bordered block -- lifecycle_stage and
359
+ // functional_role are independently-searched answers, not levels
360
+ // of one tree, and should visually read that way, not just blend
361
+ // together as one continuous list.
362
+ tipHtml += '<div class="gv-tt-annot-axis">';
363
+ tipHtml += `<div class="gv-tt-annot-axis-label"><span>${axis.label}</span><span class="gv-tt-annot-axis-meta">${meta}</span></div>`;
364
+ tipHtml += '<div class="gv-tt-annot-group">';
365
+ for (const [level, entry] of [['category', axis.category], ['annotation', axis.leaf]]) {
366
+ const cls = entry.resolved ? '' : ' gv-tt-annot-unresolved';
367
+ tipHtml += `<div class="gv-tt-annot-item${cls}"><div class="gv-tt-annot-level">${level}</div>`
368
+ + `<div class="gv-tt-annot-text"><span>${entry.text}</span><span class="gv-tt-annot-sim">${(entry.sim * 100).toFixed(0)}%</span></div></div>`;
369
+ }
370
+ tipHtml += '</div></div>';
371
+ }
372
+ }
373
+ if (gn.homology) {
374
+ // Third, independent annotation signal: plain ESM-embedding
375
+ // nearest-neighbor homology (PHROG/SwissProt via Qdrant) -- its own
376
+ // bordered block so it's never confused with the CLIP-projected
377
+ // hierarchy axes above. Each row is independently optional.
378
+ const h = gn.homology;
379
+ if (h.topHit || h.otherTopHit || h.majorityVote) {
380
+ tipHtml += '<div class="gv-tt-annot-axis">';
381
+ tipHtml += '<div class="gv-tt-annot-axis-label"><span>Homology (ESM)</span></div>';
382
+ tipHtml += '<div class="gv-tt-annot-group">';
383
+ if (h.topHit) {
384
+ const pct = h.topHit.score != null ? `${(h.topHit.score * 100).toFixed(0)}%` : '';
385
+ tipHtml += `<div class="gv-tt-annot-item"><div class="gv-tt-annot-level">top hit${h.topHit.source ? ` · ${h.topHit.source}` : ''}${h.topHit.id ? ` · ${h.topHit.id}` : ''}</div>`
386
+ + `<div class="gv-tt-annot-text"><span>${h.topHit.text}</span><span class="gv-tt-annot-sim">${pct}</span></div></div>`;
387
+ }
388
+ if (h.otherTopHit) {
389
+ const pct = h.otherTopHit.score != null ? `${(h.otherTopHit.score * 100).toFixed(0)}%` : '';
390
+ tipHtml += `<div class="gv-tt-annot-item"><div class="gv-tt-annot-level">other source${h.otherTopHit.source ? ` · ${h.otherTopHit.source}` : ''}${h.otherTopHit.id ? ` · ${h.otherTopHit.id}` : ''}</div>`
391
+ + `<div class="gv-tt-annot-text"><span>${h.otherTopHit.text}</span><span class="gv-tt-annot-sim">${pct}</span></div></div>`;
392
+ }
393
+ if (h.majorityVote) {
394
+ const frac = h.majorityVote.voteFrac;
395
+ const voteCls = frac == null ? '' : frac >= 0.7 ? ' gv-tt-annot-conf-high' : frac >= 0.4 ? ' gv-tt-annot-conf-mid' : ' gv-tt-annot-conf-low';
396
+ const voteBadge = frac != null ? `<span class="gv-tt-annot-conf${voteCls}">${(frac * 100).toFixed(0)}% agree</span>` : '';
397
+ tipHtml += `<div class="gv-tt-annot-item"><div class="gv-tt-annot-level">majority vote${h.majorityVote.id ? ` · ${h.majorityVote.id}` : ''}</div>`
398
+ + `<div class="gv-tt-annot-text"><span>${h.majorityVote.text}</span>${voteBadge}</div></div>`;
399
+ }
400
+ tipHtml += '</div></div>';
401
+ }
402
+ }
403
+ if (gn.community !== undefined) {
404
+ tipHtml += `<div class="gv-tt-row"><span>community:</span> ${gn.community}</div>`;
405
+ }
406
+ if (gn.hoverData) {
407
+ for (const [key, val] of Object.entries(gn.hoverData)) {
408
+ tipHtml += `<div class="gv-tt-row"><span>${key}:</span> ${val}</div>`;
409
+ }
410
+ }
411
+ const pinnedTipHtml = tipHtml + '<div class="gv-tt-pin">click to unpin · dbl-click to copy protein FASTA</div>';
412
+
413
+ const geneEl = this.geneG.append('path')
414
+ .attr('d', pathD).attr('fill', color).attr('fill-opacity', 0.85)
415
+ .attr('stroke', color).attr('stroke-width', 0.4).style('cursor', 'pointer')
416
+ .on('mouseover', function () {
417
+ if (self._pinnedGeneIdx !== null) return;
418
+ self._showTooltip(tipHtml);
419
+ self._dimChordsExcept(i);
420
+ d3.select(this).attr('fill-opacity', 1);
421
+ })
422
+ .on('mouseout', function () {
423
+ if (self._pinnedGeneIdx !== null) return;
424
+ self._hideTooltip();
425
+ self._restoreChords();
426
+ d3.select(this).attr('fill-opacity', 0.85);
427
+ })
428
+ .on('click', function (event) {
429
+ event.stopPropagation();
430
+ const node = this;
431
+
432
+ // Two quick clicks on the same gene → double-click → copy
433
+ if (self._clickTimer) {
434
+ clearTimeout(self._clickTimer);
435
+ self._clickTimer = null;
436
+ if (self._lastClickedIdx === i) {
437
+ self._copyGeneFasta(gn);
438
+ return;
439
+ }
440
+ }
441
+
442
+ self._lastClickedIdx = i;
443
+ self._clickTimer = setTimeout(() => {
444
+ self._clickTimer = null;
445
+
446
+ if (self._pinnedGeneIdx === i) {
447
+ // Unpin
448
+ self._pinnedGeneIdx = null;
449
+ self._hideTooltip();
450
+ self._restoreChords();
451
+ d3.select(node).attr('fill-opacity', 0.85);
452
+ } else {
453
+ // Unpin previously pinned gene
454
+ if (self._pinnedGeneIdx !== null) {
455
+ const prev = self._geneNodes[self._pinnedGeneIdx];
456
+ if (prev) d3.select(prev).attr('fill-opacity', 0.85);
457
+ }
458
+ // Pin this gene
459
+ self._pinnedGeneIdx = i;
460
+ self._showTooltip(pinnedTipHtml, true);
461
+ self._isolateChordsFor(i);
462
+ d3.select(node).attr('fill-opacity', 1);
463
+ }
464
+ }, 220);
465
+ });
466
+
467
+ this._geneNodes[i] = geneEl.node();
468
+ this.geneElements.push({ element: geneEl, angS, angE, color });
469
+ });
470
+
471
+ this.labelG.append('text').attr('text-anchor', 'middle').attr('y', -12)
472
+ .attr('fill', 'var(--phlam-accent)').attr('font-size', '19')
473
+ .attr('font-family', "'Azeret Mono', monospace")
474
+ .text((genomeSize / 1e3).toFixed(0) + ' kb');
475
+ this.labelG.append('text').attr('text-anchor', 'middle').attr('y', 11)
476
+ .attr('fill', 'var(--phlam-muted)').attr('font-size', '13')
477
+ .attr('font-family', "'Azeret Mono', monospace")
478
+ .text(genes.length + ' genes');
479
+ }
480
+
481
+ updateArcs(matrix, percentile = 0) {
482
+ this.chordG.selectAll('.chord').remove();
483
+ if (!this.genomeData || !this.genomeData.genes || this.genomeData.genes.length === 0) return;
484
+ if (!matrix || matrix.length === 0) return;
485
+
486
+ const genes = this.genomeData.genes;
487
+ const genomeSize = this.genomeData.genomeSize;
488
+ const N = genes.length;
489
+
490
+ const ang = pos => (pos / genomeSize) * Math.PI * 2 - Math.PI / 2;
491
+ const pt = (r, a) => [Math.cos(a) * r, Math.sin(a) * r];
492
+ const rChord = this.R - this.rH - 8;
493
+
494
+ const geneMatrix = matrix.slice(1).map(row => row.slice(1));
495
+
496
+ const offDiag = [];
497
+ for (let i = 0; i < N && i < geneMatrix.length; i++) {
498
+ for (let j = 0; j < N && j < geneMatrix[i].length; j++) {
499
+ if (i !== j) offDiag.push(geneMatrix[i][j]);
500
+ }
501
+ }
502
+ offDiag.sort((a, b) => a - b);
503
+ if (offDiag.length === 0) return;
504
+
505
+ const threshIdx = Math.floor(offDiag.length * (percentile / 100));
506
+ const threshVal = offDiag[Math.min(threshIdx, offDiag.length - 1)] ?? 0;
507
+ const maxVal = offDiag[offDiag.length - 1] ?? 1;
508
+
509
+ for (let i = 0; i < N && i < geneMatrix.length; i++) {
510
+ for (let j = 0; j < N && j < geneMatrix[i].length; j++) {
511
+ if (i === j) continue;
512
+ const val = geneMatrix[i][j];
513
+ if (val <= threshVal) continue;
514
+ const norm = (val - threshVal) / (maxVal - threshVal + 1e-9);
515
+ // Width is the primary encoding of attention strength; opacity gives a
516
+ // secondary depth cue on top and still drives hover isolation below.
517
+ const width = 0.75 + 5.5 * norm;
518
+ const opacity = 0.25 + 0.55 * norm;
519
+ const midI = (genes[i].start + genes[i].end) / 2;
520
+ const midJ = (genes[j].start + genes[j].end) / 2;
521
+ const [x1, y1] = pt(rChord, ang(midI));
522
+ const [x2, y2] = pt(rChord, ang(midJ));
523
+
524
+ this.chordG.append('path')
525
+ .attr('class', `chord chord-i-${i} chord-i-${j}`)
526
+ .attr('d', `M ${x1} ${y1} Q 0 0 ${x2} ${y2}`)
527
+ .attr('fill', 'none').attr('stroke', 'var(--phlam-accent)')
528
+ .attr('stroke-width', width).attr('stroke-opacity', opacity)
529
+ .attr('data-base-opacity', opacity)
530
+ .attr('marker-end', 'url(#arrowhead)');
531
+ }
532
+ }
533
+
534
+ // Re-apply ego-network isolation if a gene is pinned
535
+ if (this._pinnedGeneIdx !== null) {
536
+ this._isolateChordsFor(this._pinnedGeneIdx);
537
+ }
538
+ }
539
+
540
+ }
541
+
542
+ window.CircularGenomeViewer = CircularGenomeViewer;
static/lib/d3.LICENSE ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright 2010-2023 Mike Bostock
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any purpose
4
+ with or without fee is hereby granted, provided that the above copyright notice
5
+ and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
9
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
11
+ OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
12
+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
13
+ THIS SOFTWARE.
static/lib/d3.v7.9.0.min.js ADDED
The diff for this file is too large to render. See raw diff
 
static/style.css CHANGED
@@ -1,3 +1,8 @@
1
- body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
2
  textarea { width: 100%; font-family: monospace; }
3
- #result { background: #f4f4f4; padding: 1rem; overflow-x: auto; white-space: pre-wrap; }
 
 
 
 
 
 
1
+ body { font-family: sans-serif; max-width: 1000px; margin: 2rem auto; padding: 0 1rem; }
2
  textarea { width: 100%; font-family: monospace; }
3
+ table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-size: 0.9rem; }
4
+ th, td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; text-align: left; }
5
+ th { background: #f0f0f0; }
6
+ .job-panel { margin-top: 1.5rem; }
7
+ .job-status { color: #666; font-style: italic; }
8
+ .job-result, #result { background: #f4f4f4; padding: 1rem; overflow-x: auto; white-space: pre-wrap; max-height: 400px; overflow-y: auto; }
static/synteny/synteny-view.css ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* synteny-view.css - styles for synteny-view.js. Colours come from these variables; override them on .sv
2
+ (or an ancestor) to theme the viewer. Light theme only for now. */
3
+ .sv {
4
+ --sv-ink: #191A19; --sv-ink2: #57544F; --sv-ink3: #6E6A63; --sv-line: #EDEAE4; --sv-border: #C9C4BB;
5
+ --sv-soft: #FCFBF9; --sv-accent: oklch(0.52 0.11 195); --sv-accent-bg: #EAF6F5; --sv-track: #8F897D;
6
+ --sv-mono: 'Azeret Mono', ui-monospace, monospace; --sv-sans: Figtree, system-ui, sans-serif;
7
+ position: relative; font-family: var(--sv-sans); color: var(--sv-ink);
8
+ }
9
+ .sv-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 28px 10px; }
10
+ .sv-btn { height: 28px; padding: 0 11px; border: 1.25px solid var(--sv-border); border-radius: 6px; background: #fff;
11
+ font: 500 13px var(--sv-sans); color: var(--sv-ink); cursor: pointer; }
12
+ .sv-btn:hover { border-color: var(--sv-ink); }
13
+ .sv-range { margin-left: 8px; font: 400 12.5px var(--sv-mono); color: var(--sv-ink2); }
14
+ .sv-hint { flex-basis: 100%; font-size: 12.5px; color: var(--sv-ink3); }
15
+ .sv-ctl { display: inline-flex; align-items: center; gap: 7px; margin-left: 14px; font-size: 13px; color: var(--sv-ink2); cursor: pointer; }
16
+ .sv-thr { margin-left: auto; cursor: default; }
17
+ .sv-lab { font: 500 11.5px var(--sv-mono); letter-spacing: .06em; color: var(--sv-ink3); }
18
+ .sv-select { height: 28px; border: 1.25px solid var(--sv-border); border-radius: 6px; background: #fff; font: 400 12.5px var(--sv-mono); color: var(--sv-ink); }
19
+ .sv-range-input { width: 150px; accent-color: var(--sv-ink); cursor: pointer; }
20
+ .sv-num { width: 74px; height: 28px; border: 1.25px solid var(--sv-border); border-radius: 6px; padding: 0 6px; font: 400 12.5px var(--sv-mono); }
21
+ .sv-ctl input[type=checkbox] { accent-color: var(--sv-ink); }
22
+ .sv-body { display: flex; align-items: flex-start; }
23
+ .sv-labels { position: relative; flex: none; }
24
+ .sv-plot { position: relative; flex: 1 1 auto; min-width: 0; margin-right: 20px; }
25
+ .sv-svg { display: block; cursor: grab; touch-action: none; user-select: none; }
26
+ .sv-svg:active { cursor: grabbing; }
27
+
28
+ .sv-track { position: absolute; left: 28px; right: 8px; transform: translateY(-50%); display: flex; align-items: center; gap: 6px; }
29
+ .sv-tname { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
30
+ .sv-name { font: 600 13px var(--sv-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
31
+ .sv-name.sv-link { cursor: pointer; }
32
+ .sv-name.sv-link:hover { text-decoration: underline; }
33
+ .sv-sub { font: 400 11px var(--sv-mono); color: var(--sv-ink3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
34
+ .sv-tbtns { flex: none; display: flex; gap: 3px; }
35
+ .sv-tbtn { width: 21px; height: 19px; padding: 0; border: 1.25px solid #ADA89D; border-radius: 4px; background: #F2F0EC;
36
+ font-size: 10.5px; color: #4A473F; cursor: pointer; line-height: 1; }
37
+ .sv-tbtn:disabled { background: var(--sv-soft); color: #C9C4BB; border-color: #DDD9D2; cursor: default; }
38
+ .sv-tbtn.sv-on { background: var(--sv-accent-bg); border-color: var(--sv-accent); color: var(--sv-accent); }
39
+
40
+ .sv-axis text { font: 400 10.5px var(--sv-mono); fill: var(--sv-ink3); }
41
+ .sv-axis path, .sv-axis line { stroke: var(--sv-border); }
42
+ .sv-lines line { stroke: var(--sv-track); stroke-width: 1.5; }
43
+ .sv-gene { stroke: var(--sv-ink); stroke-width: 0.6; cursor: pointer; }
44
+ .sv-gene:hover { filter: brightness(1.12); stroke-width: 1.2; }
45
+ .sv-gene.sv-anchor { stroke: var(--sv-accent); stroke-width: 2; }
46
+ .sv-gene.sv-hl { stroke: var(--sv-ink); stroke-width: 2.4; }
47
+ .sv-gene-label { font: 400 10px var(--sv-sans); fill: #4A473F; pointer-events: none; }
48
+ .sv-tip { position: absolute; z-index: 20; pointer-events: none; opacity: 0; transition: opacity .08s; max-width: 360px;
49
+ background: var(--sv-ink); color: #F2F0EC; font: 13.5px/1.5 var(--sv-sans); padding: 8px 11px; border-radius: 8px;
50
+ box-shadow: 0 6px 20px rgba(0, 0, 0, .28); }
static/synteny/synteny-view.js ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // synteny-view.js - linear multi-genome synteny viewer with continuous zoom and pan.
2
+ //
3
+ // Standalone ES module: data in, events out; it knows nothing about PhageVault's API. Needs d3 v7
4
+ // (options.d3, or the global `d3` from lib/d3.v7.9.0.min.js) and synteny-view.css. See README.md.
5
+ //
6
+ // import { createSyntenyView } from "/static/synteny/synteny-view.js";
7
+ // const view = createSyntenyView(element, { onGeneClick: (gene, track) => ... });
8
+ // view.setData({ tracks, links }, { reset: true });
9
+ //
10
+ // tracks: [{ id, name, length, sub?, meta?: {key: value}, genes: [{ id, start, end, strand: 1 | -1,
11
+ // colour?, group?, label?, data? }] }] gene ids unique over all tracks; 1-based coords
12
+ // links: [{ a: geneId, b: geneId, scores: { metricKey: number } }] (or `value` for a single unnamed metric);
13
+ // drawn only between tracks adjacent on screen, and only when the active metric reaches its threshold
14
+ // metrics: [{ key, label, min, max, step, value }] the toolbar picks one and sets its threshold; the
15
+ // threshold only decides link / no link, the opacity then shows the score between threshold and max
16
+ //
17
+ // Interaction: drag = pan; Ctrl/Cmd + wheel (or pinch) = zoom at the pointer; Shift + wheel or a sideways
18
+ // trackpad swipe = pan; a plain wheel is left to the page (it scrolls). Zoom stops at maxZoomBp across the
19
+ // plot. Per track: move up / down, flip. Double-click a gene: align every track on that gene's group.
20
+
21
+ const DEFAULTS = {
22
+ d3: null,
23
+ labelWidth: 210, // px, left column with names and track buttons
24
+ labelMaxChars: 18, // longer names are middle-truncated (full name on hover)
25
+ trackSpacing: 64, // px between gene rows
26
+ geneHeight: 13,
27
+ axisHeight: 30,
28
+ maxZoomBp: 1000, // the most zoomed-in view spans this many bp
29
+ geneLabels: "auto", // "auto": label genes wide enough on screen | "all": every gene, slanted | "none"
30
+ linkColour: "gene", // "gene": colour of the upper gene | "grey" (also switchable in the toolbar)
31
+ metrics: [{ key: "value", label: "score", min: 0, max: 1, step: 0.001, value: 0 }],
32
+ linkOpacity: (v, thr, max) => 0.08 + 0.5 * Math.max(0, Math.min(1, (v - thr) / ((max - thr) || 1))),
33
+ unknownColour: "#ADA89D",
34
+ highlight: null, // Set of gene ids drawn with a heavy outline (and their shared links darker)
35
+ geneTooltip: true, // true | false | function (gene, track) -> HTML
36
+ onGeneHover: null, // (gene, track) on enter, (null) on leave
37
+ onGeneClick: null, // (gene, track); delayed so that a double-click still aligns
38
+ onTrackClick: null, // (track) when a track name is clicked
39
+ onViewChange: null, // ({ start, end }) visible range, in coordinates of the top track
40
+ };
41
+
42
+ const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
43
+ function midTrunc(s, n) {
44
+ s = String(s ?? "");
45
+ if (!n || s.length <= n) return s;
46
+ const tail = Math.ceil((n - 1) * 0.55);
47
+ return s.slice(0, n - 1 - tail) + "…" + s.slice(-tail);
48
+ }
49
+ const fmtBp = bp => Math.abs(bp) >= 1e6 ? (bp / 1e6).toFixed(2) + " Mb" : Math.abs(bp) >= 1e3 ? (bp / 1e3).toFixed(1) + " kb" : Math.round(bp) + " bp";
50
+
51
+ export function createSyntenyView(host, options = {}) {
52
+ const d3 = options.d3 || globalThis.d3;
53
+ if (!d3) throw new Error("synteny-view: d3 v7 is required (options.d3 or a global d3)");
54
+ const opts = { ...DEFAULTS, ...options };
55
+
56
+ let data = { tracks: [], links: [] };
57
+ let geneIndex = new Map(); // gene id -> { gene, track }
58
+ let order = []; // track ids, top to bottom
59
+ const flipped = new Set();
60
+ let alignGroup = null;
61
+ const alignPref = new Map(); // track id -> preferred gene id within alignGroup
62
+ let offsets = new Map(); // track id -> bp shift applied by the alignment
63
+ let xBase = null, transform = d3.zoomIdentity, width = 0, height = 0;
64
+ let frame = 0, clickTimer = null;
65
+ let metric = opts.metrics[0].key;
66
+ const thresholds = new Map(opts.metrics.map(m => [m.key, m.value ?? m.min]));
67
+ const metricDef = () => opts.metrics.find(m => m.key === metric) || opts.metrics[0];
68
+ const score = l => l.scores ? l.scores[metric] : l.value;
69
+
70
+ // ------------------------------------------------------------------ DOM
71
+ host.innerHTML = "";
72
+ const root = d3.select(host).append("div").attr("class", "sv");
73
+ const bar = root.append("div").attr("class", "sv-bar");
74
+ const body = root.append("div").attr("class", "sv-body");
75
+ const labels = body.append("div").attr("class", "sv-labels").style("width", opts.labelWidth + "px");
76
+ const plot = body.append("div").attr("class", "sv-plot");
77
+ const svg = plot.append("svg").attr("class", "sv-svg");
78
+ const clipId = "sv-clip-" + Math.random().toString(36).slice(2, 9);
79
+ const clipRect = svg.append("defs").append("clipPath").attr("id", clipId).append("rect");
80
+ const axisG = svg.append("g").attr("class", "sv-axis");
81
+ const content = svg.append("g").attr("clip-path", `url(#${clipId})`);
82
+ const lineL = content.append("g").attr("class", "sv-lines");
83
+ const linkL = content.append("g").attr("class", "sv-links");
84
+ const geneL = content.append("g").attr("class", "sv-genes");
85
+ const textL = content.append("g").attr("class", "sv-gene-labels");
86
+ const tip = root.append("div").attr("class", "sv-tip");
87
+
88
+ const btn = (label, title, onClick) => bar.append("button").attr("type", "button").attr("class", "sv-btn")
89
+ .attr("title", title).text(label).on("click", onClick);
90
+ btn("↺ Reset", "Reset flips, order, alignment and zoom", () => api.reset());
91
+ btn("Fit", "Show whole genomes", () => api.fit());
92
+ btn("−", "Zoom out", () => zoomBy(1 / 2));
93
+ btn("+", "Zoom in", () => zoomBy(2));
94
+ const range = bar.append("span").attr("class", "sv-range");
95
+
96
+ // link threshold (per metric), gene labels, link colour
97
+ const thrBox = bar.append("span").attr("class", "sv-ctl sv-thr");
98
+ const metricSel = thrBox.append("select").attr("class", "sv-select").attr("title", "Link metric")
99
+ .on("change", e => { metric = e.target.value; syncThreshold(); draw(); });
100
+ const thrLabel = thrBox.append("span").attr("class", "sv-lab");
101
+ const thrRange = thrBox.append("input").attr("type", "range").attr("class", "sv-range-input")
102
+ .on("input", e => setThreshold(+e.target.value));
103
+ const thrNum = thrBox.append("input").attr("type", "number").attr("class", "sv-num")
104
+ .on("change", e => setThreshold(+e.target.value));
105
+ const labBox = bar.append("label").attr("class", "sv-ctl").attr("title", "Gene labels: when wide enough, all (slanted) or none");
106
+ labBox.append("span").attr("class", "sv-lab").text("LABELS");
107
+ const labSel = labBox.append("select").attr("class", "sv-select")
108
+ .on("change", e => { opts.geneLabels = e.target.value; layout(true); renderLabels(); draw(); });
109
+ labSel.selectAll("option").data([["auto", "auto"], ["all", "all"], ["none", "none"]]).join("option")
110
+ .attr("value", d => d[0]).text(d => d[1]);
111
+ const colBox = bar.append("label").attr("class", "sv-ctl").attr("title", "Colour each link like its upper gene instead of grey");
112
+ const colChk = colBox.append("input").attr("type", "checkbox")
113
+ .on("change", e => { opts.linkColour = e.target.checked ? "gene" : "grey"; draw(); });
114
+ colBox.append("span").text("colour links");
115
+ const status = bar.append("span").attr("class", "sv-hint");
116
+
117
+ function syncControls() {
118
+ metricSel.style("display", opts.metrics.length > 1 ? null : "none")
119
+ .selectAll("option").data(opts.metrics, m => m.key).join("option").attr("value", m => m.key).text(m => m.label);
120
+ metricSel.property("value", metric);
121
+ syncThreshold();
122
+ labSel.property("value", opts.geneLabels);
123
+ colChk.property("checked", opts.linkColour === "gene");
124
+ }
125
+ function syncThreshold() {
126
+ const m = metricDef(), v = thresholds.get(m.key);
127
+ thrLabel.text(`${opts.metrics.length > 1 ? "" : m.label.toUpperCase() + " "}≥`);
128
+ for (const inp of [thrRange, thrNum]) inp.attr("min", m.min).attr("max", m.max).attr("step", m.step).property("value", v);
129
+ }
130
+ function setThreshold(v) {
131
+ const m = metricDef();
132
+ if (!isFinite(v)) return;
133
+ v = Math.max(m.min, Math.min(m.max, v));
134
+ thresholds.set(m.key, v);
135
+ thrRange.property("value", v);
136
+ thrNum.property("value", v);
137
+ schedule();
138
+ }
139
+
140
+ // ------------------------------------------------------------------ geometry
141
+ const trackOf = id => data.tracks.find(t => t.id === id);
142
+ const rowY = i => opts.axisHeight + (opts.geneLabels === "all" ? 56 : 12) + i * (opts.trackSpacing + opts.geneHeight);
143
+ // world coordinate (bp) of position p on track t, with its flip and alignment offset
144
+ const world = (t, p) => (offsets.get(t.id) || 0) + (flipped.has(t.id) ? t.length - p : p);
145
+ const geneSpan = (t, g) => {
146
+ const a = world(t, g.start - 1), b = world(t, g.end);
147
+ return [Math.min(a, b), Math.max(a, b), flipped.has(t.id) ? -g.strand : g.strand];
148
+ };
149
+
150
+ function computeOffsets() {
151
+ offsets = new Map();
152
+ if (alignGroup == null) return;
153
+ const anchors = new Map();
154
+ for (const t of data.tracks) {
155
+ const pref = alignPref.get(t.id);
156
+ const g = t.genes.find(x => x.id === pref && x.group === alignGroup) || t.genes.find(x => x.group === alignGroup);
157
+ if (g) anchors.set(t.id, geneSpan(t, g)[0]);
158
+ }
159
+ if (!anchors.size) return;
160
+ const target = Math.max(...anchors.values());
161
+ for (const [id, a] of anchors) offsets.set(id, target - a);
162
+ }
163
+
164
+ function worldExtent() {
165
+ let lo = Infinity, hi = -Infinity;
166
+ for (const t of data.tracks) {
167
+ const o = offsets.get(t.id) || 0;
168
+ lo = Math.min(lo, o); hi = Math.max(hi, o + t.length);
169
+ }
170
+ return isFinite(lo) ? [lo, hi] : [0, 1];
171
+ }
172
+
173
+ // ------------------------------------------------------------------ zoom
174
+ const zoom = d3.zoom()
175
+ .filter(e => e.type === "wheel" ? (e.ctrlKey || e.metaKey) : (!e.button && e.type !== "dblclick"))
176
+ .clickDistance(4)
177
+ .on("zoom", e => { transform = e.transform; schedule(); });
178
+ svg.call(zoom).on("dblclick.zoom", null);
179
+
180
+ // a plain vertical wheel scrolls the page; Shift + wheel or a sideways swipe pans the view
181
+ svg.node().addEventListener("wheel", e => {
182
+ if (e.ctrlKey || e.metaKey) return;
183
+ const dx = e.shiftKey ? (e.deltaX || e.deltaY) : e.deltaX;
184
+ if (!e.shiftKey && Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
185
+ e.preventDefault();
186
+ svg.call(zoom.translateBy, -dx / transform.k, 0);
187
+ }, { passive: false });
188
+
189
+ function layout(keepView) {
190
+ const visible = keepView && xBase ? transform.rescaleX(xBase).domain() : null;
191
+ width = Math.max(200, plot.node().clientWidth);
192
+ height = rowY(Math.max(0, order.length - 1)) + opts.geneHeight + 18;
193
+ svg.attr("width", width).attr("height", height).attr("viewBox", `0 0 ${width} ${height}`);
194
+ labels.style("height", height + "px");
195
+ clipRect.attr("x", 0).attr("y", 0).attr("width", width).attr("height", height);
196
+ const [lo, hi] = worldExtent();
197
+ const pad = (hi - lo) * 0.01;
198
+ xBase = d3.scaleLinear().domain([lo - pad, hi + pad]).range([8, width - 8]);
199
+ const maxK = Math.max(1, (hi - lo + 2 * pad) / opts.maxZoomBp);
200
+ zoom.scaleExtent([1, maxK]).extent([[0, 0], [width, height]]).translateExtent([[0, 0], [width, height]]);
201
+ if (visible) zoomToWorld(visible[0], visible[1], true);
202
+ else svg.call(zoom.transform, d3.zoomIdentity);
203
+ }
204
+
205
+ function zoomToWorld(a, b, instant) {
206
+ const [r0, r1] = xBase.range();
207
+ const k = (r1 - r0) / Math.max(1e-9, xBase(b) - xBase(a));
208
+ const t = d3.zoomIdentity.translate(r0 - k * xBase(a), 0).scale(k);
209
+ (instant ? svg : svg.transition().duration(200)).call(zoom.transform, t);
210
+ }
211
+
212
+ function zoomBy(f) { svg.transition().duration(200).call(zoom.scaleBy, f, [width / 2, 0]); }
213
+
214
+ new ResizeObserver(() => { if (data.tracks.length && Math.abs(plot.node().clientWidth - width) > 1) { layout(true); renderLabels(); } })
215
+ .observe(plot.node());
216
+
217
+ // ------------------------------------------------------------------ drawing
218
+ function schedule() {
219
+ if (!frame) frame = requestAnimationFrame(() => { frame = 0; draw(); });
220
+ }
221
+
222
+ function arrow(x1, x2, y, h, strand) {
223
+ const w = Math.max(1, x2 - x1), head = Math.min(10, w), m = y + h / 2;
224
+ return strand >= 0
225
+ ? `M${x1},${y}H${x1 + w - head}L${x1 + w},${m}L${x1 + w - head},${y + h}H${x1}Z`
226
+ : `M${x1 + w},${y}H${x1 + head}L${x1},${m}L${x1 + head},${y + h}H${x1 + w}Z`;
227
+ }
228
+
229
+ function draw() {
230
+ if (!xBase) return;
231
+ const x = transform.rescaleX(xBase);
232
+ const h = opts.geneHeight;
233
+ const [v0, v1] = x.domain();
234
+ const tracks = order.map(trackOf);
235
+ const rowOf = new Map(order.map((id, i) => [id, i]));
236
+ const hl = opts.highlight;
237
+
238
+ // axis in coordinates of the top track (bp from its start, respecting its flip and offset)
239
+ const top = tracks[0];
240
+ if (top) {
241
+ const local = w => flipped.has(top.id) ? top.length - (w - (offsets.get(top.id) || 0)) : w - (offsets.get(top.id) || 0);
242
+ const ax = d3.scaleLinear().domain([local(v0), local(v1)]).range(x.range());
243
+ axisG.attr("transform", `translate(0,${opts.axisHeight - 6})`)
244
+ .call(d3.axisTop(ax).ticks(Math.max(2, Math.floor(width / 110))).tickFormat(fmtBp).tickSizeOuter(0));
245
+ range.text(`${top.name}: ${fmtBp(Math.max(0, Math.min(local(v0), local(v1))))} – ${fmtBp(Math.min(top.length, Math.max(local(v0), local(v1))))}`);
246
+ if (opts.onViewChange) opts.onViewChange({ start: Math.min(local(v0), local(v1)), end: Math.max(local(v0), local(v1)) });
247
+ }
248
+
249
+ lineL.selectAll("line").data(tracks, t => t.id).join("line")
250
+ .attr("x1", t => x(Math.max(v0, offsets.get(t.id) || 0)))
251
+ .attr("x2", t => x(Math.min(v1, (offsets.get(t.id) || 0) + t.length)))
252
+ .attr("y1", t => rowY(rowOf.get(t.id)) + h / 2).attr("y2", t => rowY(rowOf.get(t.id)) + h / 2);
253
+
254
+ // links between tracks that are adjacent on screen, culled to the visible range
255
+ const links = [];
256
+ const m = metricDef(), thr = thresholds.get(m.key);
257
+ for (const l of data.links) {
258
+ const v = score(l);
259
+ if (v == null || v < thr) continue;
260
+ const A = geneIndex.get(l.a), B = geneIndex.get(l.b);
261
+ if (!A || !B) continue;
262
+ let ra = rowOf.get(A.track.id), rb = rowOf.get(B.track.id);
263
+ if (Math.abs(ra - rb) !== 1) continue;
264
+ let [ua, ub] = [A, B];
265
+ if (ra > rb) { [ua, ub] = [B, A]; [ra, rb] = [rb, ra]; }
266
+ const sa = geneSpan(ua.track, ua.gene), sb = geneSpan(ub.track, ub.gene);
267
+ if (Math.max(sa[1], sb[1]) < v0 || Math.min(sa[0], sb[0]) > v1) continue;
268
+ const on = hl && hl.has(ua.gene.id) && hl.has(ub.gene.id);
269
+ links.push({ l, v, ua, ub, sa, sb, ra, rb, on });
270
+ }
271
+ linkL.selectAll("path").data(links, d => d.l.a + "|" + d.l.b).join("path")
272
+ .attr("d", d => {
273
+ const y1 = rowY(d.ra) + h, y2 = rowY(d.rb), m = (y1 + y2) / 2;
274
+ const a1 = x(d.sa[0]), a2 = x(d.sa[1]), b1 = x(d.sb[0]), b2 = x(d.sb[1]);
275
+ return `M${a1},${y1}L${a2},${y1}C${a2},${m} ${b2},${m} ${b2},${y2}L${b1},${y2}C${b1},${m} ${a1},${m} ${a1},${y1}Z`;
276
+ })
277
+ .attr("fill", d => d.on ? "#191A19" : opts.linkColour === "gene" ? (d.ua.gene.colour || opts.unknownColour) : "#4A473F")
278
+ .attr("fill-opacity", d => d.on ? 0.5 : opts.linkOpacity(d.v, thr, m.max))
279
+ .on("mousemove", (e, d) => showTip(e, `<b>${esc(d.ua.gene.label || d.ua.gene.id)}</b> ↔ <b>${esc(d.ub.gene.label || d.ub.gene.id)}</b><br>` +
280
+ opts.metrics.map(mm => { const x = d.l.scores ? d.l.scores[mm.key] : d.l.value; return x == null ? "" : `${esc(mm.label)} ${(+x).toFixed(4)}<br>`; }).join("")))
281
+ .on("mouseleave", hideTip);
282
+
283
+ // genes, culled to the visible range
284
+ const genes = [];
285
+ for (const t of tracks) {
286
+ const r = rowOf.get(t.id);
287
+ for (const g of t.genes) {
288
+ const s = geneSpan(t, g);
289
+ if (s[1] < v0 || s[0] > v1) continue;
290
+ genes.push({ g, t, s, r });
291
+ }
292
+ }
293
+ geneL.selectAll("path").data(genes, d => d.g.id).join(enter => enter.append("path")
294
+ .on("mouseenter", (e, d) => { if (opts.onGeneHover) opts.onGeneHover(d.g, d.t); })
295
+ .on("mousemove", (e, d) => { if (opts.geneTooltip) showTip(e, geneTipHtml(d.g, d.t)); })
296
+ .on("mouseleave", () => { hideTip(); if (opts.onGeneHover) opts.onGeneHover(null); })
297
+ .on("click", (e, d) => {
298
+ if (!opts.onGeneClick) return;
299
+ clearTimeout(clickTimer);
300
+ clickTimer = setTimeout(() => opts.onGeneClick(d.g, d.t), 230);
301
+ })
302
+ .on("dblclick", (e, d) => {
303
+ e.preventDefault();
304
+ clearTimeout(clickTimer);
305
+ if (d.g.group == null) return;
306
+ if (alignGroup === d.g.group) { alignGroup = null; alignPref.clear(); }
307
+ else { alignGroup = d.g.group; alignPref.clear(); alignPref.set(d.t.id, d.g.id); }
308
+ computeOffsets(); layout(false); renderLabels();
309
+ }))
310
+ .attr("class", d => "sv-gene" + (alignGroup != null && d.g.group === alignGroup ? " sv-anchor" : "") + (hl && hl.has(d.g.id) ? " sv-hl" : ""))
311
+ .attr("d", d => arrow(x(d.s[0]), x(d.s[1]), rowY(d.r), h, d.s[2]))
312
+ .attr("fill", d => d.g.colour || opts.unknownColour);
313
+
314
+ // gene labels: "auto" writes a label inside the plot above genes wide enough for it
315
+ const lab = opts.geneLabels === "none" ? [] : genes.filter(d => d.g.label && (opts.geneLabels === "all" ||
316
+ x(d.s[1]) - x(d.s[0]) >= d.g.label.length * 6.2 + 6));
317
+ textL.selectAll("text").data(lab, d => d.g.id).join("text")
318
+ .attr("class", "sv-gene-label")
319
+ .attr("x", d => opts.geneLabels === "all" ? x(d.s[0]) + 2 : (x(d.s[0]) + x(d.s[1])) / 2)
320
+ .attr("y", d => rowY(d.r) - 3)
321
+ .attr("text-anchor", opts.geneLabels === "all" ? "start" : "middle")
322
+ .attr("transform", d => opts.geneLabels === "all" ? `rotate(-35 ${x(d.s[0]) + 2} ${rowY(d.r) - 3})` : null)
323
+ .text(d => d.g.label);
324
+ }
325
+
326
+ function geneTipHtml(g, t) {
327
+ if (typeof opts.geneTooltip === "function") return opts.geneTooltip(g, t);
328
+ return `<b>${esc(g.label || g.id)}</b><br>${esc(t.name)} · ${g.start}..${g.end} (${g.strand >= 0 ? "+" : "−"})` +
329
+ (g.group != null ? `<br>group ${esc(g.group)}` : "") + `<br><i>double-click: align all tracks on this group</i>`;
330
+ }
331
+
332
+ function showTip(e, html) {
333
+ const r = root.node().getBoundingClientRect();
334
+ tip.html(html).style("opacity", 1);
335
+ const w = tip.node().offsetWidth, hh = tip.node().offsetHeight;
336
+ let left = e.clientX - r.left + 14, topPx = e.clientY - r.top + 14;
337
+ if (e.clientX + 14 + w > window.innerWidth - 8) left = e.clientX - r.left - 14 - w;
338
+ if (e.clientY + 14 + hh > window.innerHeight - 8) topPx = e.clientY - r.top - 14 - hh;
339
+ tip.style("left", left + "px").style("top", topPx + "px");
340
+ }
341
+ function hideTip() { tip.style("opacity", 0); }
342
+
343
+ // ------------------------------------------------------------------ track labels + buttons (HTML, not zoomed)
344
+ function renderLabels() {
345
+ const tracks = order.map(trackOf);
346
+ labels.selectAll("div.sv-track").data(tracks, t => t.id).join(enter => {
347
+ const d = enter.append("div").attr("class", "sv-track");
348
+ const txt = d.append("div").attr("class", "sv-tname");
349
+ txt.append("span").attr("class", "sv-name").on("click", (e, t) => { if (opts.onTrackClick) opts.onTrackClick(t); });
350
+ txt.append("span").attr("class", "sv-sub");
351
+ const b = d.append("div").attr("class", "sv-tbtns");
352
+ b.append("button").attr("type", "button").attr("class", "sv-tbtn sv-up").attr("title", "Move up").text("▲")
353
+ .on("click", (e, t) => move(t.id, -1));
354
+ b.append("button").attr("type", "button").attr("class", "sv-tbtn sv-down").attr("title", "Move down").text("▼")
355
+ .on("click", (e, t) => move(t.id, 1));
356
+ b.append("button").attr("type", "button").attr("class", "sv-tbtn sv-flip").attr("title", "Flip (reverse complement)").text("⇄")
357
+ .on("click", (e, t) => { flipped.has(t.id) ? flipped.delete(t.id) : flipped.add(t.id); computeOffsets(); layout(true); renderLabels(); });
358
+ return d;
359
+ })
360
+ .style("top", (t, i) => (rowY(i) + opts.geneHeight / 2) + "px")
361
+ .attr("title", t => [t.name + (flipped.has(t.id) ? " (reversed)" : ""),
362
+ ...Object.entries(t.meta || {}).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}: ${v}`)].join("\n"))
363
+ .call(d => {
364
+ d.select(".sv-name").text(t => midTrunc(t.name, opts.labelMaxChars)).classed("sv-link", !!opts.onTrackClick);
365
+ d.select(".sv-sub").text(t => t.sub || "");
366
+ d.select(".sv-up").property("disabled", (t, i) => i === 0);
367
+ d.select(".sv-down").property("disabled", (t, i) => i === tracks.length - 1);
368
+ d.select(".sv-flip").classed("sv-on", t => flipped.has(t.id));
369
+ });
370
+ status.text(alignGroup != null ? `aligned on ${alignGroup} · double-click it again to clear`
371
+ : "drag or Shift + wheel to pan · Ctrl/⌘ + wheel to zoom · double-click a gene to align its family");
372
+ }
373
+
374
+ function move(id, dir) {
375
+ const i = order.indexOf(id), j = i + dir;
376
+ if (j < 0 || j >= order.length) return;
377
+ [order[i], order[j]] = [order[j], order[i]];
378
+ layout(true); renderLabels();
379
+ }
380
+
381
+ // ------------------------------------------------------------------ public API
382
+ syncControls();
383
+
384
+ const api = {
385
+ /** data = { tracks, links }; reset: forget flips / order / alignment / zoom (a new set of genomes). */
386
+ setData(d, { reset = false } = {}) {
387
+ data = { tracks: d.tracks || [], links: d.links || [] };
388
+ geneIndex = new Map();
389
+ for (const t of data.tracks) for (const g of t.genes) geneIndex.set(g.id, { gene: g, track: t });
390
+ const ids = data.tracks.map(t => t.id);
391
+ if (reset) { order = ids.slice(); flipped.clear(); alignGroup = null; alignPref.clear(); }
392
+ else order = [...order.filter(id => ids.includes(id)), ...ids.filter(id => !order.includes(id))];
393
+ computeOffsets();
394
+ layout(!reset);
395
+ renderLabels();
396
+ draw();
397
+ return api;
398
+ },
399
+ /** Change options (highlight, linkColour, geneLabels, callbacks, ...) and redraw, keeping the view. */
400
+ setOptions(patch) {
401
+ const relayout = "geneLabels" in patch || "trackSpacing" in patch || "geneHeight" in patch || "maxZoomBp" in patch;
402
+ Object.assign(opts, patch);
403
+ if ("metrics" in patch) {
404
+ for (const m of opts.metrics) if (!thresholds.has(m.key)) thresholds.set(m.key, m.value ?? m.min);
405
+ if (!opts.metrics.some(m => m.key === metric)) metric = opts.metrics[0].key;
406
+ }
407
+ syncControls();
408
+ if (relayout) layout(true);
409
+ renderLabels();
410
+ draw();
411
+ return api;
412
+ },
413
+ /** { flipped: {trackId: bool}, order: [trackIds], alignGroup, alignPref: {trackId: geneId} } */
414
+ setState(st = {}) {
415
+ if (st.flipped) for (const [id, f] of Object.entries(st.flipped)) f ? flipped.add(id) : flipped.delete(id);
416
+ if (st.order) order = [...st.order.filter(id => trackOf(id)), ...order.filter(id => !st.order.includes(id))];
417
+ if ("alignGroup" in st) {
418
+ alignGroup = st.alignGroup;
419
+ alignPref.clear();
420
+ for (const [id, g] of Object.entries(st.alignPref || {})) alignPref.set(id, g);
421
+ }
422
+ computeOffsets(); layout(false); renderLabels(); draw();
423
+ return api;
424
+ },
425
+ /** Current link threshold: { metric, value } (the toolbar sets it; this is for reading it back). */
426
+ getThreshold() { return { metric, value: thresholds.get(metric) }; },
427
+ setThreshold(value, metricKey) { if (metricKey) metric = metricKey; syncThreshold(); setThreshold(value); return api; },
428
+ getState() {
429
+ return { flipped: Object.fromEntries([...flipped].map(id => [id, true])), order: order.slice(),
430
+ alignGroup, alignPref: Object.fromEntries(alignPref) };
431
+ },
432
+ reset() { flipped.clear(); alignGroup = null; alignPref.clear(); order = data.tracks.map(t => t.id);
433
+ computeOffsets(); layout(false); renderLabels(); draw(); return api; },
434
+ fit() { svg.transition().duration(200).call(zoom.transform, d3.zoomIdentity); return api; },
435
+ /** Zoom to [start, end] of a track (default: the top one), in that track's coordinates. */
436
+ zoomTo(start, end, trackId) {
437
+ const t = trackOf(trackId || order[0]);
438
+ if (t) { const a = world(t, start), b = world(t, end); zoomToWorld(Math.min(a, b), Math.max(a, b)); }
439
+ return api;
440
+ },
441
+ zoomToGene(geneId, marginBp = 2000) {
442
+ const G = geneIndex.get(geneId);
443
+ if (G) { const s = geneSpan(G.track, G.gene); zoomToWorld(s[0] - marginBp, s[1] + marginBp); }
444
+ return api;
445
+ },
446
+ /** Standalone SVG of the current view, with the track names, for figures. */
447
+ exportSVG() {
448
+ const lw = opts.labelWidth;
449
+ const clone = svg.node().cloneNode(true);
450
+ const names = order.map((id, i) => `<text x="${lw - 12}" y="${rowY(i) + opts.geneHeight / 2 + 4}" text-anchor="end" ` +
451
+ `font-family="sans-serif" font-size="12" font-weight="600">${esc(trackOf(id).name)}</text>`).join("");
452
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width + lw}" height="${height}" font-family="sans-serif" font-size="10">` +
453
+ `<rect width="100%" height="100%" fill="#fff"/>${names}<g transform="translate(${lw},0)">${clone.innerHTML}</g></svg>`;
454
+ },
455
+ destroy() { host.innerHTML = ""; },
456
+ };
457
+ return api;
458
+ }
templates/index.html CHANGED
@@ -4,6 +4,7 @@
4
  <meta charset="UTF-8">
5
  <title>PHLAM</title>
6
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
 
7
  </head>
8
  <body>
9
  <h1>PHLAM</h1>
@@ -12,15 +13,140 @@
12
  <br>
13
  <button id="run">Analyze</button>
14
  <p id="status"></p>
15
- <pre id="result"></pre>
16
 
17
- <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  document.getElementById("run").addEventListener("click", async () => {
19
  const input = document.getElementById("input").value;
20
  const statusEl = document.getElementById("status");
21
- const resultEl = document.getElementById("result");
 
 
 
22
  statusEl.textContent = "Running...";
23
- resultEl.textContent = "";
24
  try {
25
  const res = await fetch("/analyze", {
26
  method: "POST",
@@ -32,8 +158,23 @@
32
  statusEl.textContent = "Error: " + data.error;
33
  return;
34
  }
35
- statusEl.textContent = `Done: ${data.num_genes} genes predicted.`;
36
- resultEl.textContent = JSON.stringify(data, null, 2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  } catch (e) {
38
  statusEl.textContent = "Error: " + e;
39
  }
 
4
  <meta charset="UTF-8">
5
  <title>PHLAM</title>
6
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='synteny/synteny-view.css') }}">
8
  </head>
9
  <body>
10
  <h1>PHLAM</h1>
 
13
  <br>
14
  <button id="run">Analyze</button>
15
  <p id="status"></p>
 
16
 
17
+ <table id="genes" hidden>
18
+ <thead>
19
+ <tr><th>#</th><th>start</th><th>end</th><th>strand</th><th>aa</th><th>conf</th><th>top hit</th><th>other source</th></tr>
20
+ </thead>
21
+ <tbody></tbody>
22
+ </table>
23
+
24
+ <div id="jobs"></div>
25
+
26
+ <div id="synteny-section" hidden>
27
+ <h3>Synteny vs. genome neighbors</h3>
28
+ <button id="load-synteny">Load synteny (top 5 neighbors)</button>
29
+ <p id="synteny-status"></p>
30
+ <div id="synteny-plot" style="height:500px;"></div>
31
+ </div>
32
+
33
+ <script src="{{ url_for('static', filename='lib/d3.v7.9.0.min.js') }}"></script>
34
+ <script type="module">
35
+ import { createSyntenyView } from "{{ url_for('static', filename='synteny/synteny-view.js') }}";
36
+
37
+ const JOB_LABELS = {
38
+ genome_neighbors: "Genome neighbors",
39
+ protein_neighbors: "Protein neighbors (per gene, context-independent)",
40
+ gene_context_neighbors: "Gene-context neighbors (per gene)",
41
+ };
42
+
43
+ function annotationText(a) {
44
+ if (!a || !a.top_hit) return "-";
45
+ return `${a.top_hit.annotation} (${a.top_hit.source}, ${a.top_hit.score.toFixed(3)})`;
46
+ }
47
+ function otherHitText(a) {
48
+ if (!a || !a.other_source_hit) return "-";
49
+ const o = a.other_source_hit;
50
+ return `${o.annotation} (${o.source}, ${o.score.toFixed(3)})`;
51
+ }
52
+
53
+ function renderGenes(genes) {
54
+ const tbody = document.querySelector("#genes tbody");
55
+ tbody.innerHTML = "";
56
+ genes.forEach(g => {
57
+ const tr = document.createElement("tr");
58
+ tr.innerHTML = `<td>${g.order}</td><td>${g.start}</td><td>${g.end}</td><td>${g.strand}</td>
59
+ <td>${g.length_aa}</td><td>${g.confidence.toFixed(1)}</td>
60
+ <td>${annotationText(g.annotation)}</td><td>${otherHitText(g.annotation)}</td>`;
61
+ tbody.appendChild(tr);
62
+ });
63
+ document.getElementById("genes").hidden = false;
64
+ }
65
+
66
+ function jobPanel(key) {
67
+ const div = document.createElement("div");
68
+ div.className = "job-panel";
69
+ div.innerHTML = `<h3>${JOB_LABELS[key] || key}</h3><p class="job-status">pending...</p><pre class="job-result"></pre>`;
70
+ return div;
71
+ }
72
+
73
+ // Polls /job/<id> until done/error; resolves with the final {status, result, error}.
74
+ async function pollJob(jobId, { onDone } = {}) {
75
+ for (let i = 0; i < 150; i++) {
76
+ const res = await fetch(`/job/${jobId}`);
77
+ const data = await res.json();
78
+ if (data.status !== "pending") {
79
+ if (onDone) onDone(data);
80
+ return data;
81
+ }
82
+ await new Promise(r => setTimeout(r, 2000));
83
+ }
84
+ return { status: "timeout" };
85
+ }
86
+
87
+ function pollJobIntoPanel(jobId, panel) {
88
+ const statusEl = panel.querySelector(".job-status");
89
+ const resultEl = panel.querySelector(".job-result");
90
+ return pollJob(jobId, {
91
+ onDone: data => {
92
+ statusEl.textContent = data.status;
93
+ resultEl.textContent = data.status === "done"
94
+ ? JSON.stringify(data.result, null, 2) : (data.error || "job not found");
95
+ },
96
+ });
97
+ }
98
+
99
+ // tracks/links come back as phagevault-style positional tuples; synteny-view.js
100
+ // wants {id, start, end, strand} genes and {a, b, scores} links (ids unique over all tracks).
101
+ let syntenyView = null;
102
+ function renderSynteny(syn) {
103
+ const tracks = syn.tracks.map((t, ti) => ({
104
+ id: String(ti), name: t.name, length: t.length_bp,
105
+ genes: t.genes.map(([start, end, strand], gi) => ({
106
+ id: `${ti}:${gi}`, start, end, strand: strand === "-" ? -1 : 1,
107
+ })),
108
+ }));
109
+ const links = syn.links.map(([a, i, b, j, esm, phlam]) => ({
110
+ a: `${a}:${i}`, b: `${b}:${j}`, scores: { esm, phlam },
111
+ }));
112
+ if (!syntenyView) {
113
+ syntenyView = createSyntenyView(document.getElementById("synteny-plot"), {
114
+ metrics: [
115
+ { key: "esm", label: "ESM cosine", min: syn.esm_floor, max: 1, step: 0.001, value: syn.esm_floor },
116
+ { key: "phlam", label: "PHLAM cosine", min: syn.phlam_floor, max: 1, step: 0.001, value: syn.phlam_floor },
117
+ ],
118
+ });
119
+ }
120
+ syntenyView.setData({ tracks, links }, { reset: true });
121
+ }
122
+
123
+ document.getElementById("load-synteny").addEventListener("click", async () => {
124
+ const statusEl = document.getElementById("synteny-status");
125
+ statusEl.textContent = "Running...";
126
+ const res = await fetch("/synteny", {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({
130
+ analysis_id: window.PHLAM_ANALYSIS_ID,
131
+ genome_ids: window.PHLAM_NEIGHBOR_IDS.slice(0, 5),
132
+ }),
133
+ });
134
+ const data = await res.json();
135
+ if (!res.ok) { statusEl.textContent = "Error: " + data.error; return; }
136
+ const final = await pollJob(data.job);
137
+ if (final.status !== "done") { statusEl.textContent = "Error: " + (final.error || final.status); return; }
138
+ statusEl.textContent = `${final.result.links.length} links across ${final.result.tracks.length} tracks.`;
139
+ renderSynteny(final.result);
140
+ });
141
+
142
  document.getElementById("run").addEventListener("click", async () => {
143
  const input = document.getElementById("input").value;
144
  const statusEl = document.getElementById("status");
145
+ const jobsEl = document.getElementById("jobs");
146
+ document.getElementById("genes").hidden = true;
147
+ document.getElementById("synteny-section").hidden = true;
148
+ jobsEl.innerHTML = "";
149
  statusEl.textContent = "Running...";
 
150
  try {
151
  const res = await fetch("/analyze", {
152
  method: "POST",
 
158
  statusEl.textContent = "Error: " + data.error;
159
  return;
160
  }
161
+ statusEl.textContent = `Done: ${data.num_genes} genes predicted in ${data.display_name}.`;
162
+ renderGenes(data.genes);
163
+ window.PHLAM_ANALYSIS_ID = data.analysis_id;
164
+
165
+ // Poll every job concurrently -- jobs.jobs isn't in a meaningful
166
+ // order (jsonify sorts keys), and a slow job must never block a
167
+ // fast one's panel (or the synteny section) from updating.
168
+ for (const [key, jobId] of Object.entries(data.jobs || {})) {
169
+ const panel = jobPanel(key);
170
+ jobsEl.appendChild(panel);
171
+ pollJobIntoPanel(jobId, panel).then(finalData => {
172
+ if (key === "genome_neighbors" && finalData.status === "done") {
173
+ window.PHLAM_NEIGHBOR_IDS = finalData.result.map(h => h.genome_id);
174
+ document.getElementById("synteny-section").hidden = false;
175
+ }
176
+ });
177
+ }
178
  } catch (e) {
179
  statusEl.textContent = "Error: " + e;
180
  }
utils/annotation_retrieval.py CHANGED
@@ -4,11 +4,9 @@ reference_min.parquet, row i of the parquet matching vector i of the index.
4
  Table columns: id, source (swissprot / acr / phrog), annotation, category."""
5
  import os
6
 
7
- import faiss
8
- import numpy as np
9
- import pandas as pd
10
 
11
- REFERENCE_DIR = os.environ.get("REFERENCE_DATA_DIR", "/data/reference/reference")
12
  INDEX_FILENAME = os.environ.get("REFERENCE_INDEX_FILE", "reference_min.index")
13
  PARQUET_FILENAME = os.environ.get("REFERENCE_PARQUET_FILE", "reference_min.parquet")
14
 
@@ -16,50 +14,38 @@ PARQUET_FILENAME = os.environ.get("REFERENCE_PARQUET_FILE", "reference_min.parqu
16
  class AnnotationRetriever:
17
  def __init__(self, data_dir=REFERENCE_DIR, index_filename=INDEX_FILENAME,
18
  parquet_filename=PARQUET_FILENAME, top_k=16):
19
- self.top_k = top_k
20
- index_path = os.path.join(data_dir, index_filename)
21
- parquet_path = os.path.join(data_dir, parquet_filename)
22
-
23
- self.df = pd.read_parquet(parquet_path)
24
- self.index = faiss.read_index(index_path, faiss.IO_FLAG_MMAP)
25
- if len(self.df) != self.index.ntotal:
26
- raise RuntimeError(
27
- f"row-count mismatch: {parquet_filename} has {len(self.df)} rows, "
28
- f"{index_filename} has {self.index.ntotal} vectors."
29
- )
30
- self._normalize_query = (self.index.metric_type == faiss.METRIC_INNER_PRODUCT)
31
 
32
  def query(self, esm_embeddings):
33
  """esm_embeddings: [N, D] array-like. Returns a list of N dicts, one
34
  per query: {"top_hit", "other_source_hit"}."""
35
- vecs = np.asarray(esm_embeddings, dtype=np.float32)
36
- if self._normalize_query:
37
- norms = np.linalg.norm(vecs, axis=1, keepdims=True)
38
- vecs = vecs / np.clip(norms, 1e-12, None)
39
-
40
- scores, indices = self.index.search(vecs, self.top_k)
41
- return [self._summarize(s, i) for s, i in zip(scores, indices)]
42
 
43
- def _as_dict(self, score, idx):
44
- row = self.df.iloc[idx]
45
  return {
46
  "id": row["id"], "source": row["source"], "annotation": row["annotation"],
47
  "category": row["category"], "score": float(score),
48
  }
49
 
50
- def _summarize(self, scores, indices):
51
  valid = [(float(s), int(i)) for s, i in zip(scores, indices) if i >= 0]
52
  if not valid:
53
  return {"top_hit": None, "other_source_hit": None}
54
 
55
  top_score, top_idx = valid[0]
56
- top_hit = self._as_dict(top_score, top_idx)
57
- top_source = self.df.iloc[top_idx]["source"]
58
 
59
  other_source_hit = None
60
  for score, idx in valid[1:]:
61
- if self.df.iloc[idx]["source"] != top_source:
62
- other_source_hit = self._as_dict(score, idx)
63
  break
64
 
65
  return {"top_hit": top_hit, "other_source_hit": other_source_hit}
 
4
  Table columns: id, source (swissprot / acr / phrog), annotation, category."""
5
  import os
6
 
7
+ from utils.vector_store import VectorStore
 
 
8
 
9
+ REFERENCE_DIR = os.environ.get("REFERENCE_DATA_DIR", "/data/reference")
10
  INDEX_FILENAME = os.environ.get("REFERENCE_INDEX_FILE", "reference_min.index")
11
  PARQUET_FILENAME = os.environ.get("REFERENCE_PARQUET_FILE", "reference_min.parquet")
12
 
 
14
  class AnnotationRetriever:
15
  def __init__(self, data_dir=REFERENCE_DIR, index_filename=INDEX_FILENAME,
16
  parquet_filename=PARQUET_FILENAME, top_k=16):
17
+ self.store = VectorStore(
18
+ os.path.join(data_dir, index_filename),
19
+ os.path.join(data_dir, parquet_filename),
20
+ id_column=None, top_k=top_k,
21
+ )
 
 
 
 
 
 
 
22
 
23
  def query(self, esm_embeddings):
24
  """esm_embeddings: [N, D] array-like. Returns a list of N dicts, one
25
  per query: {"top_hit", "other_source_hit"}."""
26
+ scores, indices = self.store.search(esm_embeddings)
27
+ rows = self.store.fetch_rows(indices.ravel())
28
+ return [self._summarize(s, i, rows) for s, i in zip(scores, indices)]
 
 
 
 
29
 
30
+ def _as_dict(self, score, row):
 
31
  return {
32
  "id": row["id"], "source": row["source"], "annotation": row["annotation"],
33
  "category": row["category"], "score": float(score),
34
  }
35
 
36
+ def _summarize(self, scores, indices, rows):
37
  valid = [(float(s), int(i)) for s, i in zip(scores, indices) if i >= 0]
38
  if not valid:
39
  return {"top_hit": None, "other_source_hit": None}
40
 
41
  top_score, top_idx = valid[0]
42
+ top_hit = self._as_dict(top_score, rows[top_idx])
43
+ top_source = rows[top_idx]["source"]
44
 
45
  other_source_hit = None
46
  for score, idx in valid[1:]:
47
+ if rows[idx]["source"] != top_source:
48
+ other_source_hit = self._as_dict(score, rows[idx])
49
  break
50
 
51
  return {"top_hit": top_hit, "other_source_hit": other_source_hit}
utils/gene_context_retrieval.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gene-level nearest-neighbor search against the imgvr_inphared corpus,
2
+ using a query gene's phlam_protein vector (its genomic-context residual,
3
+ not just the protein sequence -- the same gene in a different genome lands
4
+ at a different point)."""
5
+ import os
6
+
7
+ from utils.vector_store import VectorStore
8
+
9
+ IMGVR_DIR = os.environ.get("IMGVR_DATA_DIR", "/data/imgvr_inphared")
10
+
11
+
12
+ class GeneContextRetriever:
13
+ def __init__(self, data_dir=IMGVR_DIR, top_k=10):
14
+ self.store = VectorStore(
15
+ os.path.join(data_dir, "phlam_protein.faiss"),
16
+ os.path.join(data_dir, "phlam_genes.parquet"),
17
+ id_column="gene_id", top_k=top_k,
18
+ )
19
+
20
+ def query(self, phlam_protein_vecs):
21
+ """phlam_protein_vecs: [N, 480] array-like. Returns a list of N hit
22
+ lists, each hit {gene_id, genome_id, gene_order, protein_id, seq_hash, norm, score}."""
23
+ return self.store.search_and_fetch(phlam_protein_vecs)
utils/genome_neighbor_retrieval.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Genome-level nearest-neighbor search against the imgvr_inphared corpus,
2
+ using a query genome's phlam_genome (h_global) vector."""
3
+ import os
4
+
5
+ from utils.vector_store import VectorStore
6
+
7
+ IMGVR_DIR = os.environ.get("IMGVR_DATA_DIR", "/data/imgvr_inphared")
8
+
9
+
10
+ class GenomeNeighborRetriever:
11
+ def __init__(self, data_dir=IMGVR_DIR, top_k=10):
12
+ self.store = VectorStore(
13
+ os.path.join(data_dir, "phlam_genome.faiss"),
14
+ os.path.join(data_dir, "phlam_genomes.parquet"),
15
+ id_column="genome_id", top_k=top_k,
16
+ )
17
+
18
+ def query(self, phlam_genome_vecs):
19
+ """phlam_genome_vecs: [N, 480] array-like. Returns a list of N hit
20
+ lists, each hit {genome_id, n_genes, norm_genome, norm_meanpool, score}."""
21
+ return self.store.search_and_fetch(phlam_genome_vecs)
utils/phlam_embedder.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PHLAM genome/protein embeddings from a genome's ESM2 protein embeddings.
2
+
3
+ phlam_genome: h_global -- one vector per genome.
4
+ phlam_protein: h_protein - mean(h_protein over the genome) -- one residual
5
+ vector per protein, i.e. each protein's deviation from its
6
+ own genome's average.
7
+ """
8
+ import os
9
+
10
+ import torch
11
+ import torch.nn.functional as F
12
+
13
+ from phlam.model import PHLAM
14
+
15
+ # matches training (phagevault/pipelines/embed_phlam.py: raw +1 -> 2, raw -1 -> 1)
16
+ STRAND_TO_ID = {"+": 2, "-": 1}
17
+
18
+ _ROOT = os.path.dirname(os.path.abspath(__file__))
19
+ MODEL_CKPT_PATH = os.environ.get(
20
+ "PHLAM_CKPT_PATH", os.path.join(os.path.dirname(_ROOT), "saved_models", "phlam_model.pt"))
21
+ NUM_LAYERS = 10
22
+ NUM_HEADS = 12
23
+
24
+
25
+ class PHLAMEmbedder:
26
+ def __init__(self, ckpt_path=MODEL_CKPT_PATH, device="cpu"):
27
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
28
+ state_dict = {k.replace("_orig_mod.", ""): v for k, v in ckpt["model_state_dict"].items()}
29
+ model = PHLAM(input_dim=480, d_model=480, output_dim=480,
30
+ num_layers=NUM_LAYERS, num_heads=NUM_HEADS, num_global=1)
31
+ model.load_state_dict(state_dict, strict=True)
32
+ model.eval()
33
+ self.model = model.to(device)
34
+ self.device = device
35
+
36
+ def embed(self, esm_embeds, strands, is_circular=True):
37
+ """esm_embeds: [L, 480] tensor/array of raw (pre-normalization) ESM
38
+ embeddings for one genome, in gene order. strands: list[str] of "+"/"-"
39
+ of length L. Returns {"phlam_genome": [480], "phlam_protein": [L, 480]}
40
+ as numpy arrays."""
41
+ strand_ids = [STRAND_TO_ID[s] for s in strands]
42
+
43
+ esm_t = torch.as_tensor(esm_embeds, dtype=torch.float32)
44
+ esm_in = F.normalize(esm_t, p=2, dim=-1).unsqueeze(0).to(self.device)
45
+ strand_t = torch.tensor([strand_ids], dtype=torch.long, device=self.device)
46
+
47
+ with torch.no_grad():
48
+ out = self.model(
49
+ strand_ids=strand_t, esm_embeds=esm_in,
50
+ is_circular=1.0 if is_circular else 0.0,
51
+ output_attentions=False,
52
+ )
53
+ h_protein = out["h_protein"][0] # [L, 480]
54
+ h_global = out["h_global"][0] # [480]
55
+ phlam_protein = h_protein - h_protein.mean(dim=0, keepdim=True)
56
+
57
+ return {
58
+ "phlam_genome": h_global.float().cpu().numpy(),
59
+ "phlam_protein": phlam_protein.float().cpu().numpy(),
60
+ }
utils/protein_neighbor_retrieval.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Protein-level nearest-neighbor search against the imgvr_inphared corpus,
2
+ using a query gene's raw ESM2-35M embedding (context-independent)."""
3
+ import os
4
+
5
+ from utils.vector_store import VectorStore
6
+
7
+ IMGVR_DIR = os.environ.get("IMGVR_DATA_DIR", "/data/imgvr_inphared")
8
+
9
+
10
+ class ProteinNeighborRetriever:
11
+ def __init__(self, data_dir=IMGVR_DIR, top_k=10):
12
+ self.store = VectorStore(
13
+ os.path.join(data_dir, "protein_esm2_t12_35M_UR50D.faiss"),
14
+ os.path.join(data_dir, "proteins.parquet"),
15
+ id_column="protein_id", top_k=top_k,
16
+ )
17
+
18
+ def query(self, esm_embeds):
19
+ """esm_embeds: [N, 480] array-like. Returns a list of N hit lists,
20
+ each hit {protein_id, seq_hash, sequence, length, norm, score}."""
21
+ return self.store.search_and_fetch(esm_embeds)
utils/synteny.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gene-level synteny links between genome tracks: a query genome (computed
2
+ locally) and/or database neighbor genomes (imgvr_inphared corpus), compared
3
+ pairwise on ESM cosine and PHLAM context-residual cosine. Ported from
4
+ phagevault/ui/app/search.py's synteny(), adapted from Milvus/Postgres to the
5
+ local FAISS/DuckDB/parquet corpus."""
6
+ import os
7
+
8
+ import numpy as np
9
+
10
+ from utils.vector_store import VectorStore
11
+
12
+ IMGVR_DIR = os.environ.get("IMGVR_DATA_DIR", "/data/imgvr_inphared")
13
+ ESM_LINK_FLOOR = float(os.environ.get("ESM_LINK_FLOOR", "0.95"))
14
+ PHLAM_LINK_FLOOR = float(os.environ.get("PHLAM_LINK_FLOOR", "0.5"))
15
+
16
+ _genes_store = None
17
+ _proteins_store = None
18
+
19
+
20
+ def _get_genes_store():
21
+ global _genes_store
22
+ if _genes_store is None:
23
+ _genes_store = VectorStore(
24
+ os.path.join(IMGVR_DIR, "phlam_protein.faiss"),
25
+ os.path.join(IMGVR_DIR, "phlam_genes.parquet"), id_column="gene_id")
26
+ return _genes_store
27
+
28
+
29
+ def _get_proteins_store():
30
+ global _proteins_store
31
+ if _proteins_store is None:
32
+ _proteins_store = VectorStore(
33
+ os.path.join(IMGVR_DIR, "protein_esm2_t12_35M_UR50D.faiss"),
34
+ os.path.join(IMGVR_DIR, "proteins.parquet"), id_column="protein_id")
35
+ return _proteins_store
36
+
37
+
38
+ def _normalize(mat):
39
+ if mat.shape[0] == 0:
40
+ return mat
41
+ norms = np.linalg.norm(mat, axis=1, keepdims=True)
42
+ return mat / np.clip(norms, 1e-12, None)
43
+
44
+
45
+ def neighbor_track(genome_id):
46
+ """A database genome's genes + per-gene ESM/PHLAM vectors, as a synteny track."""
47
+ genes_store = _get_genes_store()
48
+ rows = genes_store._con.execute(
49
+ "SELECT gene_id, gene_order, begin_pos, end_pos, strand, protein_id FROM read_parquet(?) "
50
+ "WHERE genome_id = ? ORDER BY gene_order",
51
+ [genes_store.parquet_path, genome_id],
52
+ ).fetchall()
53
+ cols = ("gene_id", "gene_order", "begin_pos", "end_pos", "strand", "protein_id")
54
+ genes = [dict(zip(cols, r)) for r in rows]
55
+
56
+ length_bp = genes_store._con.execute(
57
+ "SELECT length_bp FROM read_parquet(?) WHERE genome_id = ?",
58
+ [os.path.join(IMGVR_DIR, "phlam_genomes.parquet"), genome_id],
59
+ ).fetchone()
60
+ length_bp = length_bp[0] if length_bp else (genes[-1]["end_pos"] if genes else 0)
61
+
62
+ phl = np.stack([genes_store.index.reconstruct(int(g["gene_id"])) for g in genes]) \
63
+ if genes else np.zeros((0, 480), np.float32)
64
+ esm_store = _get_proteins_store()
65
+ esm = np.stack([esm_store.index.reconstruct(int(g["protein_id"])) for g in genes]) \
66
+ if genes else np.zeros((0, 480), np.float32)
67
+
68
+ return {
69
+ "genome_id": genome_id, "name": f"genome_{genome_id}", "length_bp": length_bp,
70
+ "genes": genes, "esm": _normalize(esm), "phlam": _normalize(phl),
71
+ }
72
+
73
+
74
+ def query_track(genome_name, genome_length, genes, esm_embeds, phlam_protein):
75
+ """genes: pipeline.py's gene_records (start/end/strand keys), same order
76
+ as esm_embeds/phlam_protein."""
77
+ return {
78
+ "genome_id": None, "name": genome_name, "length_bp": genome_length,
79
+ "genes": [{"gene_id": None, "begin_pos": g["start"], "end_pos": g["end"], "strand": g["strand"]}
80
+ for g in genes],
81
+ "esm": _normalize(np.asarray(esm_embeds, dtype=np.float32)),
82
+ "phlam": _normalize(np.asarray(phlam_protein, dtype=np.float32)),
83
+ }
84
+
85
+
86
+ def build_synteny(tracks):
87
+ """tracks: list of dicts from query_track/neighbor_track, in display
88
+ order. Returns {tracks, esm_floor, phlam_floor, links} ready for
89
+ synteny-view.js (links as [track_a, gene_i, track_b, gene_j, esm, phlam])."""
90
+ links = []
91
+ for a in range(len(tracks)):
92
+ for b in range(a + 1, len(tracks)):
93
+ ea, eb = tracks[a]["esm"], tracks[b]["esm"]
94
+ if ea.shape[0] == 0 or eb.shape[0] == 0:
95
+ continue
96
+ pa, pb = tracks[a]["phlam"], tracks[b]["phlam"]
97
+ se = ea @ eb.T
98
+ sp = pa @ pb.T if pa.shape[0] and pb.shape[0] else np.zeros_like(se)
99
+ for i, j in zip(*np.nonzero((se >= ESM_LINK_FLOOR) | (sp >= PHLAM_LINK_FLOOR))):
100
+ links.append([a, int(i), b, int(j), round(float(se[i, j]), 4), round(float(sp[i, j]), 4)])
101
+
102
+ out_tracks = [{
103
+ "genome_id": t.get("genome_id"), "name": t.get("name"), "length_bp": t.get("length_bp"),
104
+ "genes": [[g["begin_pos"], g["end_pos"], g["strand"], g.get("gene_id")] for g in t["genes"]],
105
+ } for t in tracks]
106
+ return {"tracks": out_tracks, "esm_floor": ESM_LINK_FLOOR, "phlam_floor": PHLAM_LINK_FLOOR, "links": links}
utils/vector_store.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generic FAISS + DuckDB/Parquet nearest-neighbor store, mounted read-only
2
+ (hfmount). FAISS is mmap'd; the parquet table is never fully loaded --
3
+ DuckDB queries it directly, pushing id filters down to row-group pruning.
4
+
5
+ Two id modes, matching how an index was built:
6
+ id_column=None -- plain index, vector i <-> parquet row i (positional).
7
+ Table is small enough to load once (e.g. the ~10MB
8
+ annotation reference); searched ids ARE row positions.
9
+ id_column=<col> -- IndexIDMap2 built with add_with_ids(vecs, <col> values)
10
+ (e.g. genome_id/gene_id). Searched ids are real key
11
+ values; fetch_rows does a WHERE <col> IN (...) lookup
12
+ with no full-table load, for tables too big to load.
13
+ """
14
+ import duckdb
15
+ import faiss
16
+ import numpy as np
17
+
18
+
19
+ class VectorStore:
20
+ def __init__(self, index_path, parquet_path, id_column=None, top_k=16):
21
+ self.parquet_path = parquet_path
22
+ self.id_column = id_column
23
+ self.top_k = top_k
24
+ self.index = faiss.read_index(index_path, faiss.IO_FLAG_MMAP)
25
+ self._normalize = (self.index.metric_type == faiss.METRIC_INNER_PRODUCT)
26
+ self._con = duckdb.connect()
27
+
28
+ if id_column is None:
29
+ self.table = self._con.execute(
30
+ "SELECT * FROM read_parquet(?)", [parquet_path]).df()
31
+ n_rows = len(self.table)
32
+ else:
33
+ self.table = None
34
+ n_rows = self._con.execute(
35
+ "SELECT count(*) FROM read_parquet(?)", [parquet_path]).fetchone()[0]
36
+
37
+ if n_rows != self.index.ntotal:
38
+ raise RuntimeError(
39
+ f"row-count mismatch: {parquet_path} has {n_rows} rows, "
40
+ f"{index_path} has {self.index.ntotal} vectors."
41
+ )
42
+
43
+ def search(self, vectors, k=None):
44
+ """vectors: [N, D] array-like. Returns (scores, ids), each [N, k] --
45
+ ids are row positions (id_column=None) or key values (id_column set);
46
+ -1 pads rows with fewer than k hits."""
47
+ vecs = np.asarray(vectors, dtype=np.float32)
48
+ if self._normalize:
49
+ norms = np.linalg.norm(vecs, axis=1, keepdims=True)
50
+ vecs = vecs / np.clip(norms, 1e-12, None)
51
+ return self.index.search(vecs, k or self.top_k)
52
+
53
+ def fetch_rows(self, ids):
54
+ """ids: iterable of row positions or key values (matching id_column
55
+ mode), -1 entries ignored. Returns {id: row_dict}."""
56
+ ids = sorted({int(i) for i in ids if i >= 0})
57
+ if not ids:
58
+ return {}
59
+ if self.id_column is None:
60
+ return {i: self.table.iloc[i].to_dict() for i in ids}
61
+ placeholders = ",".join("?" * len(ids))
62
+ rows = self._con.execute(
63
+ f"SELECT * FROM read_parquet(?) WHERE {self.id_column} IN ({placeholders})",
64
+ [self.parquet_path] + ids,
65
+ ).fetchall()
66
+ cols = [d[0] for d in self._con.description]
67
+ return {row[cols.index(self.id_column)]: dict(zip(cols, row)) for row in rows}
68
+
69
+ def search_and_fetch(self, vectors, k=None):
70
+ """One search + one batched fetch_rows for the whole query batch.
71
+ Returns a list of per-query hit lists: [{**row, "score": float}, ...]."""
72
+ scores, ids = self.search(vectors, k)
73
+ rows = self.fetch_rows(ids.ravel())
74
+ results = []
75
+ for s_row, i_row in zip(scores, ids):
76
+ results.append([
77
+ {**rows[int(i)], "score": float(s)}
78
+ for s, i in zip(s_row, i_row) if i >= 0 and int(i) in rows
79
+ ])
80
+ return results