Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 10 additions & 20 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,32 +1,22 @@
# Code owners — GitHub auto-requests a review from these owners on matching PRs.
# This is the enforcement behind "agents write, humans merge" (ADR-0005): code and
# tests must be reviewed by a code owner. See docs/explanation/pull-requests-and-branch-protection.md
# tests must be reviewed by an engineer. See docs/explanation/pull-requests-and-branch-protection.md
#
# Code owners (all have write access):
# @boxiangliu — Boxiang Liu (lead)
# @gaojunbin — Junbin Gao
# @GhostAnderson — Laurentius
# @liufei-f — Liu Fei
# @MickYang2333 — YANG Chen
# @wenjing-gakkilove — Wenjing
# Engineers / code owners (all have write access):
# @boxiangliu — Boxiang Liu (lead)
# @gaojunbin — Junbin Gao
# @GhostAnderson — Laurentius
#
# NOTE: a code owner is only effective if they have WRITE access to the repo.
# Any one code-owner approval satisfies branch protection.

# Code and tests.
/src/ @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove
/tests/ @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove
/src/ @boxiangliu @gaojunbin @GhostAnderson
/tests/ @boxiangliu @gaojunbin @GhostAnderson

# CI, containers, and guardrails.
/.github/ @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove
/Dockerfile @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove
/.pre-commit-config.yaml @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove

# UI / design surfaces — owned by the UI/UX designer (Liu Fei). These come AFTER
# /src/ above so they take precedence for these paths (CODEOWNERS = last match wins),
# making @liufei-f the required reviewer for design docs and the web templates.
/docs/design/ @liufei-f
/src/locusview/templates/ @liufei-f
/.github/ @boxiangliu @gaojunbin @GhostAnderson
/Dockerfile @boxiangliu @gaojunbin @GhostAnderson
/.pre-commit-config.yaml @boxiangliu @gaojunbin @GhostAnderson

# Product, process, and course docs can be reviewed by anyone on the team;
# no CODEOWNERS entry means the default reviewers apply.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
# Allow scientific unicode (×, −, ², β, r², −log₁₀) in docstrings/comments/strings.
ignore = ["RUF001", "RUF002", "RUF003"]

# ── pytest + coverage gate ─────────────────────────────────────────────────
[tool.pytest.ini_options]
Expand Down
97 changes: 97 additions & 0 deletions src/locusview/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ def eqtls_for_gene(
"""Return eQTL associations for ``gene_id`` within the given datasets."""
...

def cis_associations(self, gene_id: int, dataset_id: int) -> list[EqtlAssociation]:
"""ALL cis variants for one gene in ONE tissue (the regional-plot set, ~thousands)."""
...

def ld_r2(self, chrom: str, lead_rs_id: int, population: str) -> dict[int, float]:
"""r² of every partner variant to the lead, from the 1000G LD panel (excludes the lead)."""
...

def tissues_with_signal(
self, gene_id: int, p_threshold: float = 1e-5
) -> list[tuple[int, str, float]]:
"""(dataset_id, tissue, min_pvalue) for tissues where the gene has a significant eQTL."""
...


# ── Shared pure helpers ──────────────────────────────────────────────────────

Expand All @@ -89,6 +103,20 @@ def eqtls_for_gene(
_ENSG = re.compile(r"^ENSG0*(\d+)$", re.IGNORECASE)
_GENCODE_TABLE = "gencode_v26_hg38" # GTEx v8 uses GENCODE v26 (hg38)

_RS_PARTNER = re.compile(r"^rs(\d+)$")
CHROMS = frozenset([*(str(i) for i in range(1, 23)), "X"])
POPULATIONS = frozenset({"AFR", "AMR", "EAS", "EUR", "SAS"})


def _ld_table(chrom: str, population: str) -> str:
"""Return the 1000G LD table name for a chromosome + super-population (enum-validated,
so the interpolated table name is safe)."""
if chrom not in CHROMS:
raise ValueError(f"unknown chromosome: {chrom!r}")
if population not in POPULATIONS:
raise ValueError(f"unknown population: {population!r}")
return f"tkg_p3v5a_ld_chr{chrom}_{population}"


def ensembl_number(ensembl_id: str) -> int:
"""Convert an Ensembl gene id to the integer key used by the shards.
Expand Down Expand Up @@ -168,10 +196,12 @@ def __init__(
datasets: Sequence[Dataset] | None = None,
associations: Sequence[EqtlAssociation] | None = None,
genes: Sequence[Gene] | None = None,
ld: dict[tuple[str, int, str], dict[int, float]] | None = None,
) -> None:
self._datasets = list(datasets or ())
self._associations = list(associations or ())
self._genes = list(genes or ())
self._ld = dict(ld or {})

def datasets(self) -> list[Dataset]:
return list(self._datasets)
Expand All @@ -191,6 +221,26 @@ def eqtls_for_gene(
hits = [a for a in self._associations if a.gene_id == gene_id and a.dataset_id in wanted]
return hits[:limit]

def cis_associations(self, gene_id: int, dataset_id: int) -> list[EqtlAssociation]:
return [
a for a in self._associations if a.gene_id == gene_id and a.dataset_id == dataset_id
]

def ld_r2(self, chrom: str, lead_rs_id: int, population: str) -> dict[int, float]:
return dict(self._ld.get((chrom, lead_rs_id, population), {}))

def tissues_with_signal(
self, gene_id: int, p_threshold: float = 1e-5
) -> list[tuple[int, str, float]]:
name = {d.id: d.tissue for d in self._datasets}
best: dict[int, float] = {}
for a in self._associations:
if a.gene_id == gene_id and a.pvalue is not None and a.pvalue < p_threshold:
best[a.dataset_id] = min(best.get(a.dataset_id, 1.0), a.pvalue)
return sorted(
((ds, name.get(ds, str(ds)), p) for ds, p in best.items()), key=lambda t: t[2]
)


# ── Real (locuscompare2 MySQL) ──────────────────────────────────────────────

Expand Down Expand Up @@ -253,6 +303,53 @@ def eqtls_for_gene(
finally:
conn.close()

def cis_associations(self, gene_id: int, dataset_id: int) -> list[EqtlAssociation]:
table = _shard_table(dataset_id)
rows = self._query(
f"SELECT gene_id, rs_id, chrom, position, pvalue, beta, se "
f"FROM {table} WHERE gene_id = %s",
(gene_id,),
)
return [_row_to_eqtl(dataset_id, r) for r in rows]

def ld_r2(self, chrom: str, lead_rs_id: int, population: str) -> dict[int, float]:
table = _ld_table(chrom, population)
lead = f"rs{lead_rs_id}"
# LD pairs are stored one-directional, so union both directions and dedupe with MAX.
rows = self._query(
f"SELECT partner, MAX(R2) AS r2 FROM ("
f" SELECT SNP_B AS partner, R2 FROM {table} WHERE SNP_A = %s"
f" UNION ALL"
f" SELECT SNP_A AS partner, R2 FROM {table} WHERE SNP_B = %s"
f") u GROUP BY partner",
(lead, lead),
)
out: dict[int, float] = {}
for partner, r2 in rows:
m = _RS_PARTNER.match(str(partner))
if m is None or r2 is None: # skip non-rs ids (esv/ss/…) and NULLs
continue
out[int(m.group(1))] = max(0.0, min(1.0, float(r2)))
out.pop(lead_rs_id, None) # exclude self; the caller sets the lead's own r² = 1.0
return out

def tissues_with_signal(
self, gene_id: int, p_threshold: float = 1e-5
) -> list[tuple[int, str, float]]:
# Fan-out across dataset shards. NOTE: O(#tissues) queries — a candidate for a
# per-gene summary table (schema-change-coordination) if it gets hot.
out: list[tuple[int, str, float]] = []
for dataset in self.datasets():
table = _shard_table(dataset.id)
# pvalue is stored as text; `+ 0.0` forces numeric MIN (handles decimal + scientific).
rows = self._query(
f"SELECT MIN(pvalue + 0.0) FROM {table} WHERE gene_id = %s", (gene_id,)
)
min_p = rows[0][0] if rows else None
if min_p is not None and float(min_p) < p_threshold:
out.append((dataset.id, dataset.tissue, float(min_p)))
return sorted(out, key=lambda t: t[2])


def pymysql_connection_factory() -> ConnectionFactory:
"""Build a factory that opens a read-only pymysql connection from ``LOCUSCOMPARE2_DB_*``
Expand Down
137 changes: 132 additions & 5 deletions src/locusview/templates/gene.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,34 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ gene.symbol }} — locusview</title>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 60rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
.muted { color: #666; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-size: 0.92rem; }
th, td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid #eee; }
th { border-bottom: 2px solid #ccc; }
:root {
--ink: #1e293b; --muted: #64748b; --line: #e2e8f0; --bg: #f8fafc;
--surface: #ffffff; --accent: #2563eb; --lead: #f97316;
}
body { font-family: system-ui, sans-serif; max-width: 62rem; margin: 2.5rem auto; padding: 0 1rem;
line-height: 1.5; color: var(--ink); background: var(--bg); }
a { color: var(--accent); }
.muted { color: var(--muted); }
h1 { font-size: 2.2rem; font-weight: 700; letter-spacing: -0.03em; margin-bottom: 0.25rem; }
h2 { font-size: 1.3rem; font-weight: 600; letter-spacing: -0.01em; margin: 1.6rem 0 0.4rem; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 1rem 1.2rem; box-shadow: 0 1px 3px rgba(15,23,42,0.04); }
.controls { display: flex; gap: 1rem; flex-wrap: wrap; align-items: end; margin-bottom: 0.6rem; }
.controls label { display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 2px; }
select { font: inherit; padding: 0.35rem 0.5rem; border: 1px solid var(--line); border-radius: 8px;
background: var(--surface); color: var(--ink); }
#lv-plot { width: 100%; height: 420px; transition: opacity 0.15s; }
table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; font-size: 0.92rem; }
th, td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
th { border-bottom: 2px solid #cbd5e1; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.wrap { overflow-x: auto; }
.legend { display: flex; gap: 0.8rem; flex-wrap: wrap; font-size: 0.78rem; color: var(--muted);
margin-top: 0.4rem; }
.swatch { display: inline-block; width: 0.7rem; height: 0.7rem; border-radius: 2px;
vertical-align: middle; margin-right: 3px; }
</style>
</head>
<body>
Expand All @@ -22,6 +42,39 @@ <h1>{{ gene.symbol }}</h1>
(strand {{ gene.strand }}) &middot; internal gene id {{ gene.gene_id }}
</p>

<h2>Regional plot</h2>
<div class="card">
<div class="controls">
<div>
<label for="lv-tissue">Tissue</label>
<select id="lv-tissue">
{% for d in datasets %}<option value="{{ d.id }}">{{ d.tissue }}</option>{% endfor %}
</select>
</div>
<div>
<label for="lv-population">LD population</label>
<select id="lv-population">
{% for p in ["EUR", "AFR", "AMR", "EAS", "SAS"] %}<option value="{{ p }}">{{ p }}</option>{% endfor %}
</select>
</div>
</div>
<div id="lv-plot"><p class="muted">Loading…</p></div>
<div class="legend">
<span><span class="swatch" style="background:#f97316"></span>lead (click any point to re-pick)</span>
<span><span class="swatch" style="background:#DB3D11"></span>0.8&ndash;1.0</span>
<span><span class="swatch" style="background:#F8C32A"></span>0.6&ndash;0.8</span>
<span><span class="swatch" style="background:#6EFE68"></span>0.4&ndash;0.6</span>
<span><span class="swatch" style="background:#26BCE1"></span>0.2&ndash;0.4</span>
<span><span class="swatch" style="background:#463699"></span>0.0&ndash;0.2</span>
<span><span class="swatch" style="background:#AAAAAA"></span>no LD data</span>
</div>
<p class="muted" style="font-size:0.78rem;margin-top:0.5rem">
LD = 1000G phase 3 (selected population) — a single-population approximation and a visual aid to
locus structure, not part of the association inference. The dotted line is genome-wide
significance (5&times;10<sup>&minus;8</sup>).
</p>
</div>

<h2>eQTLs</h2>
<p class="muted">
Showing eQTLs from {{ n_tissues }} of {{ total_tissues }} datasets (bounded for the MVP).
Expand Down Expand Up @@ -55,5 +108,79 @@ <h2>eQTLs</h2>
{% endif %}

<p class="muted">locusview v{{ version }}</p>

<script>
const GENE = {{ gene.symbol | tojson }};
const plotDiv = document.getElementById("lv-plot");
const tissueSel = document.getElementById("lv-tissue");
const popSel = document.getElementById("lv-population");
const LD_BINS = [[0.2, "#463699"], [0.4, "#26BCE1"], [0.6, "#6EFE68"], [0.8, "#F8C32A"], [1.01, "#DB3D11"]];
const LEAD_COLOR = "#f97316", NULL_COLOR = "#AAAAAA";
let DATA = null, clickBound = false;

function r2color(r2, isLead) {
if (isLead) return LEAD_COLOR;
if (r2 === null || r2 === undefined) return NULL_COLOR;
for (const [upper, color] of LD_BINS) { if (r2 < upper) return color; }
return "#DB3D11";
}
function render(colors) {
const v = DATA.variants;
const traces = [{
type: "scattergl", mode: "markers",
x: v.map(d => d.position), y: v.map(d => d.log_pvalue),
customdata: v.map(d => [d.rs_id, d.pvalue, d.r2]),
marker: {
color: colors || v.map(d => d.color),
size: v.map(d => d.is_lead ? 12 : 7),
symbol: v.map(d => d.is_lead ? "diamond" : "circle"), line: { width: 0 },
},
hovertemplate: "rs%{customdata[0]} · chr" + DATA.region.chrom +
":%{x}<br>p=%{customdata[1]:.2e} · r²=%{customdata[2]}<extra></extra>",
}];
const layout = {
margin: { t: 8, r: 8, b: 44, l: 56 }, hovermode: "closest",
xaxis: { title: "Position — chr" + DATA.region.chrom, gridcolor: "#f1f5f9", zeroline: false },
yaxis: { title: "−log₁₀(p)", gridcolor: "#f1f5f9", zeroline: false },
plot_bgcolor: "#ffffff", paper_bgcolor: "#ffffff", font: { color: "#334155", size: 12 },
shapes: [{ type: "line", xref: "paper", x0: 0, x1: 1, y0: 7.301, y1: 7.301,
line: { color: "#94a3b8", width: 1, dash: "dot" } }],
};
Plotly.react(plotDiv, traces, layout, { responsive: true, displayModeBar: false });
if (!clickBound) { plotDiv.on("plotly_click", onClick); clickBound = true; }
}
async function onClick(ev) {
const rs = ev.points[0].customdata[0];
if (rs === null) return;
const u = "/api/ld?chrom=" + DATA.region.chrom + "&lead=" + rs +
"&population=" + encodeURIComponent(popSel.value);
const resp = await fetch(u);
if (!resp.ok) return;
const m = (await resp.json()).r2;
render(DATA.variants.map(d => {
const isLead = String(d.rs_id) === String(rs);
const key = String(d.rs_id);
const r2 = isLead ? 1.0 : (key in m ? m[key] : null);
return r2color(r2, isLead);
}));
}
async function load() {
plotDiv.style.opacity = "0.4";
const u = "/api/gene/" + encodeURIComponent(GENE) + "/regional?tissue=" +
tissueSel.value + "&population=" + encodeURIComponent(popSel.value);
try {
const resp = await fetch(u);
if (!resp.ok) throw new Error("no data");
DATA = await resp.json();
render();
} catch (e) {
plotDiv.innerHTML = "<p class='muted'>No plot data for this tissue.</p>";
}
plotDiv.style.opacity = "1";
}
tissueSel.addEventListener("change", load);
popSel.addEventListener("change", load);
if (tissueSel.options.length) load();
</script>
</body>
</html>
48 changes: 48 additions & 0 deletions src/locusview/viz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Visualization helpers for the regional plot: the LocusZoom-style LD color scheme and a
little math. Pure functions, unit-tested — the frozen color contract lives here so the API and
any future front-end agree.
"""

from __future__ import annotations

import math

# LocusZoom.js default r² color scheme. Bins are broken at 0.2 / 0.4 / 0.6 / 0.8.
LD_LEAD_COLOR = "#f97316" # the reference/lead variant (orange diamond, per Liu Fei's design)
LD_NULL_COLOR = "#AAAAAA" # no LD data — distinct from low r²
LD_BINS: list[tuple[float, str]] = [
(0.2, "#463699"), # indigo 0.0–0.2
(0.4, "#26BCE1"), # cyan 0.2–0.4
(0.6, "#6EFE68"), # green 0.4–0.6
(0.8, "#F8C32A"), # yellow 0.6–0.8
(1.01, "#DB3D11"), # red 0.8–1.0
]


def r2_color(r2: float | None, *, is_lead: bool = False) -> str:
"""Map an r² value to its LocusZoom color. ``None`` → grey; the lead → purple."""
if is_lead:
return LD_LEAD_COLOR
if r2 is None:
return LD_NULL_COLOR
for upper, color in LD_BINS:
if r2 < upper:
return color
return LD_BINS[-1][1]


def ld_legend() -> list[dict[str, str]]:
"""A legend the front-end can render: label + color per r² bin (plus lead + no-data)."""
labels = ["0.0–0.2", "0.2–0.4", "0.4–0.6", "0.6–0.8", "0.8–1.0"]
legend = [{"label": lab, "color": col} for lab, (_, col) in zip(labels, LD_BINS, strict=True)]
legend.append({"label": "lead", "color": LD_LEAD_COLOR})
legend.append({"label": "no LD data", "color": LD_NULL_COLOR})
return legend


def neg_log10_p(pvalue: float | None) -> float | None:
"""−log₁₀(p) for the y-axis. Returns ``None`` for non-positive or absent p (the caller
drops those points)."""
if pvalue is None or pvalue <= 0:
return None
return -math.log10(pvalue)
Loading
Loading