noisemarseille commited on
Commit ·
6c4ade3
1
Parent(s): 56f6815
basic functionalities
Browse files- .gitignore +1 -0
- app.py +119 -0
- phlam/__init__.py +0 -0
- phlam/attention.py +275 -0
- phlam/model.py +277 -0
- pipeline.py +19 -1
- requirements.txt +1 -0
- static/circular-viewer.css +208 -0
- static/circular_genome_viewer.js +542 -0
- static/lib/d3.LICENSE +13 -0
- static/lib/d3.v7.9.0.min.js +0 -0
- static/style.css +7 -2
- static/synteny/synteny-view.css +50 -0
- static/synteny/synteny-view.js +458 -0
- templates/index.html +147 -6
- utils/annotation_retrieval.py +16 -30
- utils/gene_context_retrieval.py +23 -0
- utils/genome_neighbor_retrieval.py +21 -0
- utils/phlam_embedder.py +60 -0
- utils/protein_neighbor_retrieval.py +21 -0
- utils/synteny.py +106 -0
- utils/vector_store.py +80 -0
.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 · <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:
|
| 2 |
textarea { width: 100%; font-family: monospace; }
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
document.getElementById("run").addEventListener("click", async () => {
|
| 19 |
const input = document.getElementById("input").value;
|
| 20 |
const statusEl = document.getElementById("status");
|
| 21 |
-
const
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
-
import numpy as np
|
| 9 |
-
import pandas as pd
|
| 10 |
|
| 11 |
-
REFERENCE_DIR = os.environ.get("REFERENCE_DATA_DIR", "/data/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.
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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 |
-
|
| 36 |
-
|
| 37 |
-
|
| 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,
|
| 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 =
|
| 58 |
|
| 59 |
other_source_hit = None
|
| 60 |
for score, idx in valid[1:]:
|
| 61 |
-
if
|
| 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
|