diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4629c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +# A new push makes the previous run pointless. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - run: pip install -e ".[dev]" + - run: pytest -q + + splice: + name: Python 3.12 with MaxEntScan + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + # The default splice backend prefers MaxEntScan when it is installed, so + # without this job that path is never exercised in CI. + - run: pip install -e ".[dev]" "git+https://github.com/kepbod/maxentpy.git" + - name: Confirm MaxEntScan really is in use + run: python -c "from codonopt.splice import maxent_available; assert maxent_available()" + - run: pytest -q + + web: + name: Interface (type-check, build, end-to-end) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + - run: pip install -e . + - run: npm ci + working-directory: web + - name: Type-check and verify the committed build is current + run: npm run check + working-directory: web + - run: npx playwright install --with-deps chromium + working-directory: web + - name: End-to-end tests against the Python server + run: npm test + working-directory: web + env: + CI: "true" + # Traces and screenshots, so a failure here can be read rather than guessed at. + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: | + web/playwright-report + web/test-results + retention-days: 7 diff --git a/.gitignore b/.gitignore index 71dbf08..8ee937f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ dist/ .pytest_cache/ .venv/ venv/ + +# web interface tooling +web/node_modules/ +web/test-results/ +web/playwright-report/ diff --git a/README.md b/README.md index 9b20491..c79142d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,52 @@ -# computational_codon_optimization +

codonopt

+ +

+ Codon optimization for gene synthesis and expression.
+ Rewrite the DNA of an open reading frame so it is easier to make and express,
+ without changing the protein it encodes. +

+ +

+ CI + Python 3.10+ + Runtime dependencies: none + Interface: TypeScript + Tests: pytest and Playwright + License: GPL v3 +

+ +

+ + + The codonopt interface after optimizing a 120-residue protein: 16 issues down to 0, with the changed codons highlighted + +

+ +Give it a protein, or an existing coding sequence, and it returns a synonymous +sequence that balances GC content, repeats, hairpins, homopolymers, forbidden +motifs, cryptic splice sites and host codon usage in a single score. It is a +pure-Python package with no runtime dependencies, a command line, a Python API +and a local web interface. -Rewrites the DNA of an open reading frame so it is easier to synthesise and -express, without changing the protein it encodes. +## Highlights -Give it a protein (or an existing coding sequence) and it returns a synonymous -sequence that balances GC content, repeats, hairpins, homopolymers, forbidden -motifs, cryptic splice sites and host codon usage in a single score. +- **One objective, not four passes.** Every concern is a weighted penalty in one score, so fixing one problem cannot silently undo another. +- **Fast.** Scoring is incremental: a codon swap re-scores only what sits near it. An 800-residue protein takes 19 s, against 234 s for the scripts this replaces. +- **The protein cannot change.** The only move is swapping a codon for a synonymous one, so correctness holds by construction, not by checking afterwards. +- **Three ways in.** `codonopt` on the command line, a small Python API, and a browser or desktop interface. +- **Checked against brute force.** The tests compare the incremental score with a from-scratch recomputation and with simple reference implementations. ## Quickstart ```bash pip install -e . +codonopt gui # the web interface (opens in your browser) +``` + +Or from the command line: + +```bash codonopt optimize --add-stop --avoid golden_gate,EcoRI --seed 5 --splice-backend motif \ --protein MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEENFKALVLIAFAQYLQQCPF ``` @@ -38,6 +73,21 @@ Splice acceptor sites 1 0 `--json FILE` writes the same numbers, the per-term score and the sequence. +## Web interface + +`codonopt gui` starts a local server and opens the interface in a native window +if [pywebview](https://pywebview.flowrl.com/) is installed (`pip install -e ".[gui]"`), +otherwise in your browser. Paste or open a protein or DNA sequence, pick a host +table and what to keep out, and press **Optimize**. You get: + +- the sequence with every changed codon highlighted, and the protein above it; +- a before/after table, a GC plot along the sequence and the score breakdown; +- copy and download for the DNA, FASTA and a report; runs can be cancelled and keep the best result so far. + +It is local only: the server binds to loopback, checks Host and Origin, requires +a per-run token, sends a strict content security policy and never touches your +files. Add `--no-window --no-browser` to just serve it. + ## What it optimizes | Criterion | Why | Default | @@ -51,10 +101,7 @@ Splice acceptor sites 1 0 | Cryptic splice sites | Unwanted splicing of a transgene | MaxEntScan or a consensus screen | | Codon usage | Rare codons slow translation | host codon adaptation index | -The protein cannot change: the only move the optimizer has is swapping a codon -for a synonymous one. - -## Commands +## Command line ```bash codonopt optimize --protein-fasta gene.faa --out gene.fna # design from a protein @@ -77,7 +124,7 @@ codonopt motifs # known motifs and g `codonopt optimize --help` lists the rest. MaxEntScan is optional and not on PyPI: `pip install "git+https://github.com/kepbod/maxentpy.git"`. -## Python +## Python API ```python from codonopt import DesignConfig, OptimizerSettings, get_table, optimize, resolve_motifs @@ -92,8 +139,8 @@ improves an existing sequence. ## How it works -Each penalty term is tied to a position — a GC window, a homopolymer, a motif -match, a splice window, a codon — so a codon swap only re-scores what sits near +Each penalty term is tied to a position (a GC window, a homopolymer, a motif +match, a splice window, a codon), so a codon swap only re-scores what sits near it. Repeats and hairpins are not local, so an incrementally updated k-mer index carries them. A candidate swap costs time proportional to the window sizes, not the sequence length. @@ -104,6 +151,29 @@ followed by simulated annealing and several restarts. incremental path against it and against brute-force implementations in `codonopt.metrics`. +## Development + +The Python package needs nothing but Python. The interface is written in +TypeScript in [`web/`](web/) and compiled with esbuild to plain HTML, CSS and +JavaScript in `src/codonopt/web/`. That output is committed, so running the +interface never needs Node; Node is only for working on it. + +```bash +pip install -e ".[dev]" && pytest # Python side + +cd web && npm install +npm run build # regenerate src/codonopt/web +npm run check # type-check, and fail if the committed build is stale +npm test # Playwright end-to-end tests against the real Python server +``` + +``` +src/codonopt/ optimizer, scoring, CLI and the local server (stdlib only) +web/ TypeScript and CSS sources, build script, Playwright tests +benchmarks/ reproducible performance comparison +legacy/ the two original scripts this replaces, kept for reference +``` + ## History This replaces two work-in-progress scripts, kept in [`legacy/`](legacy/). They @@ -114,13 +184,6 @@ takes 19 s instead of 234 s and ends with a better codon adaptation index (0.90 vs 0.72) and no forbidden motifs (vs four). Details and a reproducible benchmark are in [`legacy/README.md`](legacy/README.md). -## Development - -```bash -pip install -e ".[dev]" -pytest -``` - ## Limitations - Hairpins are inverted repeats, not free-energy folds. @@ -128,3 +191,7 @@ pytest for production work. The consensus splice screen is a filter, not a model. - The search is local. A given `--seed` is reproducible, but another seed may do slightly better; use `--restarts` or `--anneal` when it counts. + +## License + +[GPL-3.0](LICENSE) © Thom de Hoog diff --git a/docs/interface-dark.png b/docs/interface-dark.png new file mode 100644 index 0000000..12b3b4f Binary files /dev/null and b/docs/interface-dark.png differ diff --git a/docs/interface-light.png b/docs/interface-light.png new file mode 100644 index 0000000..9060f87 Binary files /dev/null and b/docs/interface-light.png differ diff --git a/pyproject.toml b/pyproject.toml index 365dad1..d4d409a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [] # MaxEntScan splice site scoring. Not on PyPI, so it is installed from source: # pip install "git+https://github.com/kepbod/maxentpy.git" splice = ["maxentpy"] +# Optional native window for `codonopt gui`; without it the browser is used. +gui = ["pywebview"] dev = ["pytest>=7"] [project.scripts] @@ -30,6 +32,11 @@ codonopt = "codonopt.cli:main" [tool.setuptools.packages.find] where = ["src"] +# The pre-built web interface ships inside the package, so using it needs no +# Node toolchain. +[tool.setuptools.package-data] +codonopt = ["web/*"] + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" diff --git a/src/codonopt/__main__.py b/src/codonopt/__main__.py new file mode 100644 index 0000000..395bc05 --- /dev/null +++ b/src/codonopt/__main__.py @@ -0,0 +1,7 @@ +"""Allow ``python -m codonopt``.""" + +import sys + +from .cli import main + +sys.exit(main()) diff --git a/src/codonopt/cli.py b/src/codonopt/cli.py index 2db539d..5519d0b 100644 --- a/src/codonopt/cli.py +++ b/src/codonopt/cli.py @@ -2,6 +2,7 @@ codonopt optimize --protein-fasta gene.faa --table human --out gene.fna codonopt report --dna-fasta gene.fna + codonopt gui codonopt tables codonopt motifs """ @@ -71,6 +72,29 @@ def build_parser() -> argparse.ArgumentParser: ) report_parser.set_defaults(handler=_run_report) + gui_parser = subparsers.add_parser( + "gui", + help="open the graphical interface (a native window with pywebview, " + "otherwise your browser)", + ) + gui_parser.add_argument( + "--port", type=int, default=0, metavar="N", help="port to listen on (default: any free one)" + ) + gui_parser.add_argument( + "--no-window", + action="store_true", + help="serve the interface without opening a native window", + ) + gui_parser.add_argument( + "--no-browser", + action="store_true", + help="do not open the default browser (use with --no-window)", + ) + gui_parser.add_argument( + "--verbose", action="store_true", help="log each request to stderr" + ) + gui_parser.set_defaults(handler=_run_gui) + tables_parser = subparsers.add_parser("tables", help="list codon usage tables") tables_parser.set_defaults(handler=_run_tables) @@ -388,6 +412,17 @@ def _run_report(args: argparse.Namespace) -> int: return 0 +def _run_gui(args: argparse.Namespace) -> int: + from .server import run_gui + + return run_gui( + args.port, + window=not args.no_window, + open_browser=not args.no_browser, + verbose=args.verbose, + ) + + def _run_tables(args: argparse.Namespace) -> int: for name in available_tables(): table = get_table(name) diff --git a/src/codonopt/config.py b/src/codonopt/config.py index 759074d..4bca2e8 100644 --- a/src/codonopt/config.py +++ b/src/codonopt/config.py @@ -149,6 +149,9 @@ class OptimizerSettings: codons by host frequency, ``"best"`` takes the most frequent codon everywhere, ``"random"`` samples uniformly. progress: Called with a short status line after each phase. + should_stop: Called regularly during the search; once it returns True + the search stops cleanly and returns the best sequence found so + far, which is how a caller cancels a long run. """ restarts: int = 1 @@ -160,6 +163,7 @@ class OptimizerSettings: seed: int | None = None seed_mode: str = "weighted" progress: object | None = None + should_stop: object | None = None def __post_init__(self) -> None: if self.restarts < 1: diff --git a/src/codonopt/metrics.py b/src/codonopt/metrics.py index 33bec6a..e9682cb 100644 --- a/src/codonopt/metrics.py +++ b/src/codonopt/metrics.py @@ -19,6 +19,7 @@ "HomopolymerRun", "RepeatReport", "Hairpin", + "gc_profile", "gc_window_report", "homopolymer_runs", "homopolymer_excess", @@ -133,6 +134,24 @@ def gc_window_report( ) +def gc_profile( + sequence: str, *, window: int = 30, step: int = 3 +) -> list[tuple[int, float]]: + """Return ``(start, GC fraction)`` for every scored window. + + The same windows :func:`gc_window_report` scores, so a plot of this profile + shows exactly what the penalty sees. + """ + if not sequence: + return [] + prefix = gc_prefix(sequence) + profile = [] + for start in window_starts(len(sequence), window, step): + end = min(start + window, len(sequence)) + profile.append((start, (prefix[end] - prefix[start]) / (end - start))) + return profile + + def _check_fraction_range(target_range: tuple[float, float]) -> tuple[float, float]: low, high = target_range if not (0.0 <= low <= high <= 1.0): diff --git a/src/codonopt/optimize.py b/src/codonopt/optimize.py index 574e12b..c484cc7 100644 --- a/src/codonopt/optimize.py +++ b/src/codonopt/optimize.py @@ -61,6 +61,7 @@ class OptimizationResult: restarts_run: Restarts actually run before the time budget ran out. elapsed: Wall-clock seconds spent. hit_time_limit: True if the time budget stopped the search early. + cancelled: True if ``should_stop`` stopped the search early. """ sequence: str @@ -76,6 +77,7 @@ class OptimizationResult: restarts_run: int = 0 elapsed: float = 0.0 hit_time_limit: bool = False + cancelled: bool = False notes: list[str] = field(default_factory=list) @property @@ -148,6 +150,13 @@ def optimize( deadline = ( started + settings.max_seconds if settings.max_seconds is not None else None ) + should_stop: Callable[[], bool] | None = settings.should_stop # type: ignore[assignment] + + def expired() -> bool: + """True once the time budget is spent or the caller asked to stop.""" + if should_stop is not None and should_stop(): + return True + return deadline is not None and time.perf_counter() >= deadline best: SequenceState | None = None best_initial: tuple[str, float] | None = None @@ -155,7 +164,7 @@ def optimize( hit_limit = False for restart in range(settings.restarts): - if deadline is not None and time.perf_counter() >= deadline and best is not None: + if best is not None and expired(): hit_limit = True break seed = ( @@ -172,7 +181,7 @@ def optimize( ) local_sweeps, local_evaluated, local_accepted, stopped = _greedy( - state, rng, settings, deadline + state, rng, settings, expired ) sweeps += local_sweeps evaluated += local_evaluated @@ -181,7 +190,7 @@ def optimize( if settings.anneal_steps and not stopped: annealed, anneal_evaluated, anneal_accepted, stopped = _anneal( - state, rng, settings, deadline + state, rng, settings, expired ) evaluated += anneal_evaluated accepted += anneal_accepted @@ -201,6 +210,7 @@ def optimize( if translate(best.sequence) != protein: # pragma: no cover - safety net raise AssertionError("optimizer changed the encoded protein") + cancelled = bool(hit_limit and should_stop is not None and should_stop()) initial_state = SequenceState(best_initial[0], config) return OptimizationResult( sequence=best.sequence, @@ -215,7 +225,8 @@ def optimize( moves_accepted=accepted, restarts_run=restarts_run, elapsed=time.perf_counter() - started, - hit_time_limit=hit_limit, + hit_time_limit=hit_limit and not cancelled, + cancelled=cancelled, ) @@ -235,7 +246,7 @@ def _greedy( state: SequenceState, rng: random.Random, settings: OptimizerSettings, - deadline: float | None, + expired: Callable[[], bool], ) -> tuple[int, int, int, bool]: """Run greedy sweeps until nothing improves. Returns counters.""" order = list(range(state.codon_count)) @@ -245,9 +256,8 @@ def _greedy( sweeps += 1 improved = 0 for counter, position in enumerate(order): - if deadline is not None and not counter % 64: - if time.perf_counter() >= deadline: - return sweeps, evaluated, accepted, True + if not counter % 64 and expired(): + return sweeps, evaluated, accepted, True options = state.alternatives(position) if len(options) == 1: continue @@ -274,7 +284,7 @@ def _anneal( state: SequenceState, rng: random.Random, settings: OptimizerSettings, - deadline: float | None, + expired: Callable[[], bool], ) -> tuple[SequenceState | None, int, int, bool]: """Run simulated annealing, returning the best state seen. @@ -299,7 +309,7 @@ def _anneal( stopped = False for step in range(steps): - if deadline is not None and not step % 256 and time.perf_counter() >= deadline: + if not step % 256 and expired(): stopped = True break position = rng.choice(positions) diff --git a/src/codonopt/server.py b/src/codonopt/server.py new file mode 100644 index 0000000..0dbdb8f --- /dev/null +++ b/src/codonopt/server.py @@ -0,0 +1,772 @@ +"""Local web server behind the codonopt interface. + +Standard library only, so the interface works wherever the package does. The +browser (or a ``pywebview`` window) loads a pre-built page from +``codonopt/web`` and talks to this module over a small JSON API: + + GET /api/meta tables, motif groups, defaults, limits + POST /api/jobs start an optimisation, returns {"id": ...} + GET /api/jobs/ progress, and the result once finished + POST /api/jobs//cancel stop early and keep the best sequence so far + +Optimisation can take minutes, so a request starts a job on a worker thread and +the page polls for its state rather than holding a connection open. + +The server binds to the loopback interface only, and still defends itself, +because anything running in the user's browser can reach ``127.0.0.1``: + +* the ``Host`` header must name the loopback address and this server's port, + which stops DNS rebinding; +* every ``/api`` call must carry a random per-process token that is only ever + embedded in the page this server serves, which stops other sites from driving + it; +* the API never reads or writes files -- sequences and tables arrive as text; +* responses carry a strict Content-Security-Policy. +""" + +from __future__ import annotations + +import hmac +import json +import math +import secrets +import sys +import threading +import time +import traceback +import uuid +import webbrowser +from dataclasses import dataclass, field, fields +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib import resources +from urllib.parse import urlsplit + +from . import __version__ +from .config import DesignConfig, OptimizerSettings, Weights +from .fasta import Record, format_fasta, parse_fasta +from .metrics import gc_profile, motif_hits +from .motifs import ( + MOTIF_GROUPS, + REGULATORY_MOTIFS, + RESTRICTION_SITES, + resolve_motifs, +) +from .optimize import OptimizationResult, optimize +from .report import report_dict +from .sequence import translate, validate_dna, validate_protein +from .splice import get_scorer, maxent_available +from .tables import TABLES, get_table, table_from_text + +__all__ = [ + "App", + "JobSpec", + "RequestError", + "build_request", + "create_server", + "run_gui", +] + +# -------------------------------------------------------------------------- +# Limits. The server is local, but a runaway request should still be bounded. +# -------------------------------------------------------------------------- + +MAX_BODY_BYTES = 2_000_000 +MAX_RESIDUES = 15_000 +MAX_SECONDS = 900 +MAX_RESTARTS = 20 +MAX_ANNEAL_STEPS = 1_000_000 +MAX_RUNNING_JOBS = 2 +MAX_KEPT_JOBS = 30 + +#: How hard each effort level searches. +EFFORT: dict[str, dict[str, int]] = { + "quick": {"max_sweeps": 3, "restarts": 1, "anneal_steps": 0}, + "standard": {"max_sweeps": 6, "restarts": 1, "anneal_steps": 0}, + "thorough": {"max_sweeps": 8, "restarts": 3, "anneal_steps": 5000}, +} + +EXAMPLE_NAME = "example" +EXAMPLE_PROTEIN = ( + "MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEENFKALVLIAFAQYLQQCPF" + "DEHVKLVNELTEFAKTCVADESHAGCEKSLHTLFGDELCKVASLRETYGDMADCCEKQEP" +) + +_ASSETS: dict[str, tuple[str, str]] = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/app.css": ("app.css", "text/css; charset=utf-8"), + "/favicon.svg": ("favicon.svg", "image/svg+xml"), +} + +_CSP = ( + "default-src 'none'; script-src 'self'; style-src 'self'; " + "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " + "form-action 'none'; frame-ancestors 'none'" +) +_TOKEN_PLACEHOLDER = "__CODONOPT_TOKEN__" +_TOKEN_HEADER = "X-Codonopt-Token" + + +class RequestError(ValueError): + """The request was understood but cannot be acted on; the text is for the user.""" + + +# -------------------------------------------------------------------------- +# Turning a JSON payload into a validated job +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class JobSpec: + """A validated optimisation request. + + Attributes: + name: Record name for the output. + protein: Protein to encode, with a trailing ``*`` if a stop is wanted. + start: Coding sequence to improve, or None to build one. + config: Design constraints. + settings: Keyword arguments for :class:`OptimizerSettings`. + kind: ``"protein"`` or ``"dna"``, as interpreted. + """ + + name: str + protein: str + start: str | None + config: DesignConfig + settings: dict + kind: str + + +def _number( + payload: dict, + key: str, + default, + *, + low: float, + high: float, + integer: bool = False, + label: str | None = None, +): + """Read an optional numeric field, with a readable error if it is wrong.""" + value = payload.get(key) + if value is None or value == "": + return default + label = label or key.replace("_", " ") + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise RequestError(f"{label} must be a number") + try: + # A JSON integer has no width limit, so this is where one too large for + # a float arrives; it is a bad request, not a server fault. + if not math.isfinite(value): + raise RequestError(f"{label} must be a finite number") + except OverflowError: + raise RequestError(f"{label} must be between {low:g} and {high:g}") from None + if integer and int(value) != value: + raise RequestError(f"{label} must be a whole number") + if not low <= value <= high: + raise RequestError(f"{label} must be between {low:g} and {high:g}") + return int(value) if integer else float(value) + + +def _parse_sequence(payload: dict) -> tuple[str, str, str]: + """Return ``(name, sequence, kind)`` from the pasted text.""" + raw = payload.get("sequence") + if not isinstance(raw, str) or not raw.strip(): + raise RequestError("Paste a protein or DNA sequence first.") + name = payload.get("name") + name = name.strip() if isinstance(name, str) and name.strip() else None + + text = raw.strip() + if text.startswith(">"): + records = list(parse_fasta(text.splitlines())) + if len(records) != 1: + raise RequestError( + f"Found {len(records)} FASTA records; paste one sequence at a time." + ) + name = name or records[0].name + sequence = records[0].sequence + else: + sequence = "".join(text.split()) + sequence = sequence.upper() + + kind = payload.get("kind", "auto") + if kind not in {"auto", "protein", "dna"}: + raise RequestError("kind must be auto, protein or dna") + if kind == "auto": + looks_like_dna = len(sequence) >= 9 and set(sequence) <= set("ACGTU") + kind = "dna" if looks_like_dna else "protein" + return name or "sequence", sequence, kind + + +def _build_table(payload: dict): + text = payload.get("table_text") + if isinstance(text, str) and text.strip(): + stripped = text.lstrip() + fmt = "json" if stripped.startswith("{") else ("tsv" if "\t" in text else "csv") + return table_from_text(text, fmt, name="custom", source="custom table") + if text is not None and "table" not in payload: + # A custom table was asked for and left blank. Falling back to a + # built-in host here would optimise against the wrong organism. + raise RequestError("The custom codon table is empty; paste one or choose a host.") + name = payload.get("table", "human") + if not isinstance(name, str): + raise RequestError("table must be a name") + try: + return get_table(name) + except KeyError as exc: + raise RequestError(str(exc.args[0])) from None + + +def _build_weights(raw) -> Weights: + if raw is None: + return Weights() + if not isinstance(raw, dict): + raise RequestError("weights must be an object") + known = {field.name for field in fields(Weights)} + values = {} + for key, value in raw.items(): + if key not in known: + raise RequestError(f"unknown weight {key!r}") + values[key] = _number( + {key: value}, + key, + getattr(Weights(), key), + low=0, + high=1e6, + label=f"weight {key}", + ) + return Weights(**values) + + +def build_request(payload: object) -> JobSpec: + """Validate a decoded JSON request body into a :class:`JobSpec`. + + Raises: + RequestError: With a message fit to show the user, for anything wrong + with the request. + """ + if not isinstance(payload, dict): + raise RequestError("The request body must be a JSON object.") + try: + name, sequence, kind = _parse_sequence(payload) + if kind == "dna": + start = validate_dna(sequence.replace("U", "T"), in_frame=True) + protein = translate(start) + else: + start = None + protein = validate_protein(sequence) + if payload.get("add_stop") and not protein.endswith("*"): + protein += "*" + if len(protein) > MAX_RESIDUES: + raise RequestError( + f"That is {len(protein):,} residues; the interface accepts up to " + f"{MAX_RESIDUES:,}. Use the command line for longer genes." + ) + + avoid = payload.get("avoid", []) + if not isinstance(avoid, list) or not all(isinstance(a, str) for a in avoid): + raise RequestError("avoid must be a list of motif names or patterns") + if len(avoid) > 100 or any(len(a) > 200 for a in avoid): + raise RequestError("too many, or too long, motifs to avoid") + motifs = tuple(resolve_motifs(avoid)) + + gc_range = payload.get("gc_range", [0.30, 0.70]) + if not (isinstance(gc_range, list) and len(gc_range) == 2): + raise RequestError("gc range must be two numbers") + gc_low = _number( + {"v": gc_range[0]}, "v", 0.30, low=0, high=1, label="GC range low" + ) + gc_high = _number( + {"v": gc_range[1]}, "v", 0.70, low=0, high=1, label="GC range high" + ) + + backend = payload.get("splice_backend", "auto") + if backend not in {"auto", "maxent", "motif", "none"}: + raise RequestError("splice backend must be auto, maxent, motif or none") + try: + scorer = get_scorer(backend) + except RuntimeError as exc: + raise RequestError(str(exc).splitlines()[0]) from None + + def whole(key: str, default: int, low: int, high: int) -> int: + return _number(payload, key, default, low=low, high=high, integer=True) + + config = DesignConfig( + table=_build_table(payload), + gc_target=_number(payload, "gc_target", 0.50, low=0, high=1), + gc_range=(gc_low, gc_high), + gc_window=whole("gc_window", 30, 6, 300), + gc_step=whole("gc_step", 3, 1, 300), + repeat_k=whole("repeat_k", 12, 4, 60), + hairpin_stem=whole("hairpin_stem", 10, 4, 40), + hairpin_min_loop=whole("hairpin_min_loop", 3, 0, 100), + hairpin_max_loop=whole("hairpin_max_loop", 60, 0, 300), + max_homopolymer=whole("max_homopolymer", 6, 2, 30), + motifs=motifs, + splice_scorer=scorer, + donor_cutoff=_number(payload, "donor_cutoff", None, low=-100, high=100), + acceptor_cutoff=_number( + payload, "acceptor_cutoff", None, low=-100, high=100 + ), + weights=_build_weights(payload.get("weights")), + ) + + effort = payload.get("effort", "standard") + if effort not in EFFORT: + raise RequestError("effort must be quick, standard or thorough") + settings = dict(EFFORT[effort]) + settings["restarts"] = min(settings["restarts"], MAX_RESTARTS) + settings["anneal_steps"] = min(settings["anneal_steps"], MAX_ANNEAL_STEPS) + settings["max_seconds"] = _number( + payload, "seconds", 120.0, low=1, high=MAX_SECONDS + ) + seed = _number(payload, "seed", None, low=0, high=2**31 - 1, integer=True) + if seed is not None: + settings["seed"] = seed + # Surface bad settings now rather than inside the worker thread. + OptimizerSettings(**settings) + except RequestError: + raise + except ValueError as exc: # SequenceError, FASTA, table and config errors + raise RequestError(str(exc)) from None + + return JobSpec( + name=name, + protein=protein, + start=start, + config=config, + settings=settings, + kind=kind, + ) + + +# -------------------------------------------------------------------------- +# Jobs +# -------------------------------------------------------------------------- + + +@dataclass +class Job: + """One optimisation run and everything the page needs to show about it.""" + + id: str + spec: JobSpec + status: str = "running" # running | done | cancelled | error + messages: list[str] = field(default_factory=list) + restarts_done: int = 0 + started: float = field(default_factory=time.perf_counter) + finished: float | None = None + result: dict | None = None + error: str | None = None + stop: threading.Event = field(default_factory=threading.Event) + + def snapshot(self) -> dict: + """Return the JSON-serialisable state of the job.""" + end = self.finished if self.finished is not None else time.perf_counter() + body = { + "id": self.id, + "status": self.status, + "elapsed": round(end - self.started, 2), + "max_seconds": self.spec.settings["max_seconds"], + "restarts_total": self.spec.settings["restarts"], + "restarts_done": self.restarts_done, + "messages": self.messages[-12:], + } + if self.error is not None: + body["error"] = self.error + if self.result is not None: + body["result"] = self.result + return body + + +class JobStore: + """Thread-safe registry of jobs, bounded in size and concurrency.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._jobs: dict[str, Job] = {} + + def start(self, spec: JobSpec) -> Job: + """Create a job and run it on a worker thread. + + Raises: + RequestError: If too many jobs are already running. + """ + with self._lock: + running = sum(job.status == "running" for job in self._jobs.values()) + if running >= MAX_RUNNING_JOBS: + raise RequestError( + "Already running as many optimisations as allowed; " + "wait for one to finish or cancel it." + ) + job = Job(id=uuid.uuid4().hex[:12], spec=spec) + self._jobs[job.id] = job + finished = [j for j in self._jobs.values() if j.status != "running"] + for old in finished[: max(0, len(self._jobs) - MAX_KEPT_JOBS)]: + del self._jobs[old.id] + threading.Thread(target=_run_job, args=(job,), daemon=True).start() + return job + + def get(self, job_id: str) -> Job | None: + with self._lock: + return self._jobs.get(job_id) + + +def _run_job(job: Job) -> None: + spec = job.spec + + def progress(message: str) -> None: + job.messages.append(message) + # One "final score" line is printed as each restart finishes. + if "final score" in message: + job.restarts_done += 1 + + try: + settings = OptimizerSettings( + **spec.settings, progress=progress, should_stop=job.stop.is_set + ) + result = optimize(spec.protein, spec.config, settings, start=spec.start) + job.result = result_payload(result, spec) + job.status = "cancelled" if result.cancelled else "done" + except (ValueError, KeyError) as exc: + job.error = str(exc.args[0]) if exc.args else str(exc) + job.status = "error" + except Exception as exc: # pragma: no cover - a bug, not a user error + traceback.print_exc(file=sys.stderr) + job.error = f"Unexpected error: {type(exc).__name__}: {exc}" + job.status = "error" + finally: + job.finished = time.perf_counter() + + +def result_payload(result: OptimizationResult, spec: JobSpec) -> dict: + """Everything the page needs to show a finished run.""" + config = spec.config + payload = report_dict(result, config) + profile_before = gc_profile( + result.initial_sequence, window=config.gc_window, step=config.gc_step + ) + profile_after = gc_profile( + result.sequence, window=config.gc_window, step=config.gc_step + ) + lengths = {motif.name: motif.length for motif in config.motifs} + hits = motif_hits(result.sequence, list(config.motifs)) + payload.update( + name=spec.name, + kind=spec.kind, + protein=result.protein, + initial_sequence=result.initial_sequence, + cancelled=result.cancelled, + gc={ + "window": config.gc_window, + "step": config.gc_step, + "range": list(config.gc_range), + "target": config.gc_target, + "starts": [start for start, _ in profile_after], + "initial": [round(value, 4) for _, value in profile_before], + "final": [round(value, 4) for _, value in profile_after], + }, + motif_hits=[ + {"name": name, "length": lengths[name], "positions": list(positions)} + for name, positions in hits.items() + ], + fasta=format_fasta( + Record( + name=spec.name, + description=( + f"{spec.name} codonopt table={config.table.name} " + f"score={result.score:.2f}" + ), + sequence=result.sequence, + ) + ), + ) + return payload + + +# -------------------------------------------------------------------------- +# HTTP +# -------------------------------------------------------------------------- + + +def meta_payload() -> dict: + """Static facts the page needs to build its form.""" + defaults = DesignConfig() + return { + "version": __version__, + "tables": [ + {"name": name, "description": table.description} + for name, table in sorted(TABLES.items()) + ], + "default_table": "human", + "motif_groups": {name: list(members) for name, members in MOTIF_GROUPS.items()}, + "restriction_sites": dict(sorted(RESTRICTION_SITES.items())), + "regulatory_motifs": dict(sorted(REGULATORY_MOTIFS.items())), + "splice_backends": ["auto", "maxent", "motif", "none"], + "maxent_available": maxent_available(), + "weights": { + field.name: getattr(Weights(), field.name) for field in fields(Weights) + }, + "defaults": { + "gc_target": defaults.gc_target, + "gc_range": list(defaults.gc_range), + "gc_window": defaults.gc_window, + "gc_step": defaults.gc_step, + "repeat_k": defaults.repeat_k, + "hairpin_stem": defaults.hairpin_stem, + "hairpin_min_loop": defaults.hairpin_min_loop, + "hairpin_max_loop": defaults.hairpin_max_loop, + "max_homopolymer": defaults.max_homopolymer, + "seconds": 120, + }, + "effort": EFFORT, + "limits": { + "max_residues": MAX_RESIDUES, + "max_seconds": MAX_SECONDS, + }, + "example": {"name": EXAMPLE_NAME, "protein": EXAMPLE_PROTEIN}, + } + + +class App: + """State shared by every request: the token, the jobs, the allowed hosts.""" + + def __init__(self, port: int = 0) -> None: + self.token = secrets.token_urlsafe(24) + self.jobs = JobStore() + self.port = port + + def host_allowed(self, host: str | None) -> bool: + return host is not None and host.lower() in { + f"127.0.0.1:{self.port}", + f"localhost:{self.port}", + } + + def origin_allowed(self, origin: str | None) -> bool: + return origin is None or origin.lower() in { + f"http://127.0.0.1:{self.port}", + f"http://localhost:{self.port}", + } + + def token_ok(self, supplied: str | None) -> bool: + return supplied is not None and hmac.compare_digest(supplied, self.token) + + +class _Handler(BaseHTTPRequestHandler): + # HTTP/1.0: one request per connection, so an early error response can never + # leave an unread request body to be mistaken for the next request. + server_version = "codonopt" + + def log_message(self, format: str, *args) -> None: # noqa: A002 + if getattr(self.server, "verbose", False): + super().log_message(format, *args) + + @property + def app(self) -> App: + return self.server.app # type: ignore[attr-defined] + + def do_GET(self) -> None: # noqa: N802 + self._handle("GET") + + def do_POST(self) -> None: # noqa: N802 + self._handle("POST") + + def _handle(self, method: str) -> None: + try: + path = urlsplit(self.path).path + if not self.app.host_allowed(self.headers.get("Host")): + return self._error(HTTPStatus.FORBIDDEN, "Unexpected Host header.") + if path.startswith("/api/"): + if not self.app.token_ok(self.headers.get(_TOKEN_HEADER)): + return self._error(HTTPStatus.FORBIDDEN, "Missing or wrong token.") + if method == "POST" and not self.app.origin_allowed( + self.headers.get("Origin") + ): + return self._error(HTTPStatus.FORBIDDEN, "Unexpected Origin.") + return self._api(method, path) + if method != "GET": + return self._error(HTTPStatus.METHOD_NOT_ALLOWED, "GET only.") + return self._static(path) + except (BrokenPipeError, ConnectionResetError): + return + except Exception: # pragma: no cover - a bug, not a user error + traceback.print_exc(file=sys.stderr) + self._error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.") + + # -- static ------------------------------------------------------------ + + def _static(self, path: str) -> None: + asset = _ASSETS.get(path) + if asset is None: + return self._error(HTTPStatus.NOT_FOUND, "Not found.") + filename, content_type = asset + try: + data = resources.files("codonopt").joinpath("web", filename).read_bytes() + except (FileNotFoundError, OSError): + return self._error( + HTTPStatus.INTERNAL_SERVER_ERROR, + "The interface has not been built. From a source checkout run " + "'npm install && npm run build' inside web/.", + ) + if filename == "index.html": + data = data.replace(_TOKEN_PLACEHOLDER.encode(), self.app.token.encode()) + self._send(HTTPStatus.OK, data, content_type) + + # -- api --------------------------------------------------------------- + + def _api(self, method: str, path: str) -> None: + parts = [part for part in path.split("/") if part] # ["api", ...] + if method == "GET" and parts == ["api", "meta"]: + return self._json(HTTPStatus.OK, meta_payload()) + if method == "POST" and parts == ["api", "jobs"]: + return self._start_job() + if len(parts) >= 3 and parts[:2] == ["api", "jobs"]: + job = self.app.jobs.get(parts[2]) + if job is None: + return self._error(HTTPStatus.NOT_FOUND, "No such job.") + if method == "GET" and len(parts) == 3: + return self._json(HTTPStatus.OK, job.snapshot()) + if method == "POST" and parts[3:] == ["cancel"]: + job.stop.set() + return self._json(HTTPStatus.OK, job.snapshot()) + return self._error(HTTPStatus.NOT_FOUND, "Not found.") + + def _start_job(self) -> None: + try: + length = int(self.headers.get("Content-Length", "")) + except ValueError: + return self._error(HTTPStatus.LENGTH_REQUIRED, "Content-Length required.") + if length < 0 or length > MAX_BODY_BYTES: + return self._error( + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "That request is too large." + ) + try: + # ValueError also covers a number with more digits than int() will + # parse (CPython's 4300-digit limit). + payload = json.loads(self.rfile.read(length) or b"null") + except (UnicodeDecodeError, ValueError): + return self._error(HTTPStatus.BAD_REQUEST, "The request is not valid JSON.") + try: + job = self.app.jobs.start(build_request(payload)) + except RequestError as exc: + return self._error(HTTPStatus.BAD_REQUEST, str(exc)) + self._json(HTTPStatus.ACCEPTED, job.snapshot()) + + # -- responses --------------------------------------------------------- + + def _json(self, status: HTTPStatus, body: object) -> None: + self._send( + status, + json.dumps(body, separators=(",", ":")).encode(), + "application/json", + ) + + def _error(self, status: HTTPStatus, message: str) -> None: + self._json(status, {"error": message}) + + def _send(self, status: HTTPStatus, data: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("Content-Security-Policy", _CSP) + self.end_headers() + self.wfile.write(data) + + +class _Server(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, address, handler, app: App, verbose: bool = False) -> None: + super().__init__(address, handler) + self.app = app + self.verbose = verbose + + +def create_server( + host: str = "127.0.0.1", port: int = 0, *, verbose: bool = False +) -> ThreadingHTTPServer: + """Create (but do not start) the interface server. + + Args: + host: Interface to bind; only loopback addresses are accepted. + port: Port to bind, or 0 to let the system choose a free one. + verbose: Log each request to stderr. + + Raises: + ValueError: If ``host`` is not a loopback address. + """ + if host not in {"127.0.0.1", "localhost"}: + raise ValueError( + "the interface only binds to the loopback address (127.0.0.1); " + "it has no authentication and must not be exposed to a network" + ) + app = App() + server = _Server(("127.0.0.1", port), _Handler, app, verbose) + app.port = server.server_address[1] + return server + + +def server_url(server: ThreadingHTTPServer) -> str: + """Return the address a browser should open.""" + return f"http://127.0.0.1:{server.server_address[1]}/" + + +def run_gui( + port: int = 0, + *, + window: bool = True, + open_browser: bool = True, + verbose: bool = False, +) -> int: + """Serve the interface until it is closed. + + With ``pywebview`` installed (``pip install pywebview``) and ``window`` + true, the page opens in a native window and the server stops when that + window closes. Otherwise the address is printed, the page is opened in the + default browser if ``open_browser`` is true, and the server runs until + interrupted. + """ + server = create_server(port=port, verbose=verbose) + url = server_url(server) + + webview = None + if window: + try: + import webview # type: ignore[import-not-found,no-redef] + except ImportError: + print( + "pywebview is not installed, so the interface opens in your browser " + "instead.\nInstall it for a native window: pip install pywebview", + file=sys.stderr, + ) + + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + if webview is not None: + try: # let the Download buttons save files from the native window + webview.settings["ALLOW_DOWNLOADS"] = True + except Exception: # older pywebview without this setting + pass + webview.create_window( + "codonopt", url, width=1180, height=860, min_size=(820, 600) + ) + webview.start() # blocks until the window is closed + else: + print(f"codonopt interface: {url} (Ctrl+C to stop)") + if open_browser: + webbrowser.open(url) + while thread.is_alive(): + thread.join(0.5) + except KeyboardInterrupt: + pass + finally: + server.shutdown() + server.server_close() + return 0 diff --git a/src/codonopt/tables.py b/src/codonopt/tables.py index 9aa215d..3a2b717 100644 --- a/src/codonopt/tables.py +++ b/src/codonopt/tables.py @@ -16,6 +16,7 @@ from __future__ import annotations import csv +import io import json import math import random @@ -24,7 +25,14 @@ from .sequence import AA_TO_CODONS, CODON_TO_AA, STOP, split_codons -__all__ = ["CodonUsage", "TABLES", "get_table", "load_table", "available_tables"] +__all__ = [ + "CodonUsage", + "TABLES", + "available_tables", + "get_table", + "load_table", + "table_from_text", +] # Approximate relative synonymous codon usage, Homo sapiens. _HUMAN: dict[str, float] = { @@ -225,47 +233,78 @@ def get_table(name: str) -> CodonUsage: def load_table(path: str | Path, *, name: str | None = None) -> CodonUsage: - """Load a codon usage table from a JSON or CSV file. + """Load a codon usage table from a JSON, CSV or TSV file. - JSON files hold an object mapping codon to frequency or count. CSV files - hold two columns, codon and value, with an optional header row. + JSON files hold an object mapping codon to frequency or count. CSV and TSV + files hold two columns, codon and value, with an optional header row. Raises: ValueError: If the file extension is not ``.json``, ``.csv`` or ``.tsv``, or a row cannot be parsed. """ path = Path(path) - suffix = path.suffix.lower() - if suffix == ".json": - payload = json.loads(path.read_text()) + return table_from_text( + path.read_text(), + path.suffix, + name=name or path.stem, + source=str(path), + ) + + +def table_from_text( + text: str, + fmt: str, + *, + name: str, + source: str = "table", +) -> CodonUsage: + """Build a codon usage table from the text of a JSON, CSV or TSV table. + + Args: + text: The table contents. + fmt: ``"json"``, ``"csv"`` or ``"tsv"``, with or without a leading dot. + name: Name to give the table. + source: Where the text came from, used to prefix error messages. + + Raises: + ValueError: If ``fmt`` is unsupported, a row cannot be parsed, or no + codon rows are found. + """ + fmt = fmt.lower().lstrip(".") + if fmt == "json": + payload = json.loads(text) if not isinstance(payload, dict): - raise ValueError(f"{path}: expected a JSON object of codon -> value") - frequencies = {str(k): float(v) for k, v in payload.items()} - elif suffix in {".csv", ".tsv"}: - delimiter = "\t" if suffix == ".tsv" else "," + raise ValueError(f"{source}: expected a JSON object of codon -> value") + try: + frequencies = {str(k): float(v) for k, v in payload.items()} + except (TypeError, ValueError): + raise ValueError(f"{source}: every JSON value must be a number") from None + elif fmt in {"csv", "tsv"}: + delimiter = "\t" if fmt == "tsv" else "," frequencies = {} - with path.open(newline="") as handle: - for lineno, row in enumerate(csv.reader(handle, delimiter=delimiter), 1): - if not row or row[0].startswith("#"): - continue - if len(row) < 2: - raise ValueError(f"{path}:{lineno}: expected two columns") - codon = row[0].strip().upper().replace("U", "T") - if codon not in CODON_TO_AA: - continue # header or annotation row - try: - frequencies[codon] = float(row[1]) - except ValueError: - raise ValueError( - f"{path}:{lineno}: {row[1]!r} is not a number" - ) from None + for lineno, row in enumerate( + csv.reader(io.StringIO(text, newline=""), delimiter=delimiter), 1 + ): + if not row or row[0].startswith("#"): + continue + if len(row) < 2: + raise ValueError(f"{source}:{lineno}: expected two columns") + codon = row[0].strip().upper().replace("U", "T") + if codon not in CODON_TO_AA: + continue # header or annotation row + try: + frequencies[codon] = float(row[1]) + except ValueError: + raise ValueError( + f"{source}:{lineno}: {row[1]!r} is not a number" + ) from None else: - raise ValueError(f"{path}: expected a .json, .csv or .tsv table") + raise ValueError(f"{source}: expected a .json, .csv or .tsv table") if not frequencies: - raise ValueError(f"{path}: no codon rows found") + raise ValueError(f"{source}: no codon rows found") return CodonUsage.from_frequencies( - name or path.stem, + name, frequencies, - description=f"Loaded from {path}", + description=f"Loaded from {source}", ) diff --git a/src/codonopt/web/app.css b/src/codonopt/web/app.css new file mode 100644 index 0000000..fe553b0 --- /dev/null +++ b/src/codonopt/web/app.css @@ -0,0 +1 @@ +:root{--bg: #f4f6f4;--surface: #ffffff;--surface-2: #eef1ee;--text: #17201e;--muted: #56635f;--border: #d9dfdb;--accent: #0f766e;--accent-strong: #0b5a54;--accent-ink: #ffffff;--accent-soft: #d6eeea;--good: #146c37;--bad: #b42318;--warn: #8a4b08;--warn-soft: #fdecc8;--error-soft: #fde4e1;--before: #7b8794;--focus: #1d4ed8;--shadow: 0 1px 2px rgb(16 24 22 / 8%), 0 4px 16px rgb(16 24 22 / 5%);--radius: 12px;--mono: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace;--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif}@media(prefers-color-scheme:dark){:root{--bg: #0e1413;--surface: #161e1c;--surface-2: #1d2725;--text: #e5ece9;--muted: #98a7a1;--border: #2a3835;--accent: #3fb6aa;--accent-strong: #7fd6cc;--accent-ink: #06201d;--accent-soft: #17403b;--good: #5fd38b;--bad: #ff8a80;--warn: #f2b45a;--warn-soft: #3a2a10;--error-soft: #3b1a17;--before: #8794a3;--focus: #8ab4ff;--shadow: 0 1px 2px rgb(0 0 0 / 40%)}}*{box-sizing:border-box}[hidden]{display:none!important}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 var(--sans)}:focus-visible{outline:2px solid var(--focus);outline-offset:2px}h1,h2,h3,p{margin:0}h1{font-size:1.15rem;line-height:1.2;letter-spacing:-.01em}h2{font-size:.8125rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:650}h3{font-size:1rem}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.mono{font-family:var(--mono)}.hint{color:var(--muted);font-size:.8125rem}.top{max-width:1280px;margin:0 auto;padding:16px 16px 4px;display:flex;align-items:center;justify-content:space-between;gap:12px}.brand{display:flex;align-items:center;gap:12px}.brand p{color:var(--muted);font-size:.8125rem}.logo{width:36px;height:36px;flex:none}.logo-bg{fill:var(--accent)}.logo-strand{fill:none;stroke:var(--accent-ink);stroke-width:2.4;stroke-linecap:round}.version{color:var(--muted);font:.75rem var(--mono)}.app{max-width:1280px;margin:0 auto;padding:12px 16px 48px;display:grid;gap:20px;grid-template-columns:minmax(0,1fr)}@media(min-width:980px){.app{grid-template-columns:430px minmax(0,1fr);align-items:start}.panel{position:sticky;top:16px;max-height:calc(100vh - 32px)}.panel-scroll{overflow-y:auto}}.panel{display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}.panel[inert]{opacity:.55}.panel-scroll{padding:4px 18px 8px;min-height:0}.actions{position:sticky;bottom:0;display:flex;gap:8px;padding:12px 18px;background:var(--surface);border-top:1px solid var(--border);border-radius:0 0 var(--radius) var(--radius)}.group{padding:16px 0;border-bottom:1px solid var(--border);display:grid;gap:10px}.group:last-child{border-bottom:0}.group-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.tools{display:flex;gap:12px}.row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}textarea,input[type=text],input[type=number],select{width:100%;font:inherit;color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px}textarea{font-family:var(--mono);font-size:.8125rem;line-height:1.45;resize:vertical;min-height:7rem}input::placeholder,textarea::placeholder{color:var(--muted);opacity:.8}textarea:hover,input:hover,select:hover{border-color:color-mix(in srgb,var(--border),var(--text) 25%)}.field{display:grid;gap:4px;min-width:0}.field>span,.small-label{font-size:.8125rem;color:var(--muted)}.grid3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.grid3 .wide{grid-column:span 2}.link{background:none;border:0;padding:0;font:inherit;font-size:.8125rem;color:var(--accent-strong);cursor:pointer;text-decoration:underline;text-underline-offset:3px}.link:hover{text-decoration-thickness:2px}.check{display:inline-flex;align-items:center;gap:6px;font-size:.875rem;cursor:pointer}.check input{width:16px;height:16px;accent-color:var(--accent)}.seg{position:relative;display:inline-flex;margin:0;padding:2px;min-width:0;border:1px solid var(--border);border-radius:10px;background:var(--surface-2)}.seg input{position:absolute;opacity:0;pointer-events:none}.seg label{padding:5px 12px;border-radius:8px;cursor:pointer;font-size:.875rem;color:var(--muted)}.seg input:checked+label{background:var(--surface);color:var(--text);font-weight:600;box-shadow:0 1px 2px #00000026}.seg input:focus-visible+label{outline:2px solid var(--focus);outline-offset:1px}.detected{font-size:.8125rem;color:var(--muted)}.detected.ok{color:var(--accent-strong)}.detected.error{color:var(--bad)}.chips{display:flex;flex-wrap:wrap;gap:8px}.chip-check{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;font-size:.8125rem;border:1px solid var(--border);border-radius:999px;cursor:pointer;background:var(--surface)}.chip-check:has(input:checked){background:var(--accent-soft);border-color:var(--accent);color:var(--accent-strong);font-weight:600}.chip-check input{accent-color:var(--accent)}.advanced>summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px}.advanced>summary::-webkit-details-marker{display:none}.advanced>summary:before{content:"\25b8";color:var(--muted);transition:transform .15s}.advanced[open]>summary:before{transform:rotate(90deg)}.advanced[open]{gap:12px}.weights{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}button.primary,button.secondary{font:inherit;font-weight:650;border-radius:10px;padding:10px 16px;cursor:pointer;border:1px solid transparent;flex:1}button.primary{background:var(--accent);color:var(--accent-ink)}button.primary:hover:not(:disabled){background:var(--accent-strong);color:var(--accent-ink)}button.primary:disabled{opacity:.45;cursor:not-allowed}button.secondary{background:var(--surface-2);color:var(--text);border-color:var(--border)}button.secondary:hover{border-color:var(--muted)}.output{min-width:0;display:grid;gap:16px;outline:none}.empty{padding:28px 24px;background:var(--surface);border:1px dashed var(--border);border-radius:var(--radius);display:grid;gap:14px}.empty h2{font-size:1.25rem;text-transform:none;letter-spacing:-.01em;color:var(--text);font-weight:650}.empty p{color:var(--muted);max-width:60ch}.steps{margin:0;padding-left:1.2em;display:grid;gap:6px}.steps strong{color:var(--accent-strong)}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px 18px;display:grid;gap:12px;min-width:0}.callout{padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:var(--surface-2)}.callout.error{background:var(--error-soft);border-color:var(--bad);color:var(--text);display:grid;gap:4px}.callout.error strong{color:var(--bad)}.callout.warn{background:var(--warn-soft);border-color:var(--warn);color:var(--text)}.result-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.result-head h2{font-size:1.25rem;text-transform:none;letter-spacing:-.01em;color:var(--text);font-weight:650}.toolbar{display:flex;flex-wrap:wrap;gap:8px}.chip{font:inherit;font-size:.8125rem;padding:6px 12px;border-radius:999px;cursor:pointer;background:var(--surface);color:var(--text);border:1px solid var(--border)}.chip:hover{border-color:var(--muted)}.primary-chip{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}.primary-chip:hover{background:var(--accent-strong);border-color:var(--accent-strong)}.stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(170px,1fr))}.stat{display:grid;gap:2px;padding:12px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.stat-label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:650}.stat-value{font-size:1.35rem;font-variant-numeric:tabular-nums;letter-spacing:-.01em}.stat-value .was{color:var(--muted)}.stat-value strong{color:var(--accent-strong)}.stat-note{font-size:.8125rem;color:var(--muted)}.progress{gap:10px}.bar{height:8px;border-radius:999px;background:var(--surface-2);overflow:hidden}.bar-fill{height:100%;width:0;background:var(--accent);border-radius:999px;transition:width .3s}.bar.indeterminate .bar-fill{width:35%!important;animation:slide 1.4s ease-in-out infinite}@keyframes slide{0%{transform:translate(-100%)}to{transform:translate(300%)}}@media(prefers-reduced-motion:reduce){.bar.indeterminate .bar-fill{animation:none;width:100%!important;opacity:.5}}.progress-meta{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;font-size:.8125rem}.log{margin:0;padding:8px 10px 8px 28px;max-height:9rem;overflow:auto;font-size:.75rem;color:var(--muted);background:var(--surface-2);border-radius:8px}.seq-wrap{display:grid;gap:10px;min-width:0}.seq-tools{display:flex;align-items:center;justify-content:space-between;gap:8px 16px;flex-wrap:wrap}.toggles{display:flex;gap:14px;flex-wrap:wrap}.note.warn{color:var(--warn);font-size:.8125rem}.seq{font-family:var(--mono);font-size:.8125rem;line-height:1.35;overflow-x:auto;padding:10px 12px;background:var(--surface-2);border-radius:10px;max-height:26rem;overflow-y:auto;font-variant-ligatures:none}.block{display:flex;gap:12px;padding:3px 0;white-space:nowrap}.pos{color:var(--muted);white-space:pre;user-select:none;text-align:right;flex:none}.line{display:block}.line .cell{display:inline-block;width:4ch}.line.aa{color:var(--muted);font-size:.75rem;display:none}.seq.show-aa .line.aa{display:block}.seq.show-changes .cell.chg{background:var(--accent-soft);color:var(--accent-strong);border-radius:3px;font-weight:600}.cell.hit{color:var(--bad);text-decoration:underline wavy;text-underline-offset:3px;font-weight:700}.compare{width:100%;border-collapse:collapse;font-size:.875rem}.compare th,.compare td{padding:8px 10px;border-bottom:1px solid var(--border);text-align:left}.compare thead th{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.compare tbody th{font-weight:500}.compare tbody tr:last-child th,.compare tbody tr:last-child td{border-bottom:0}.compare .num{text-align:right;font-variant-numeric:tabular-nums}.compare .strong{font-weight:650}.verdict{white-space:nowrap}.verdict.good{color:var(--good)}.verdict.bad{color:var(--bad);font-weight:600}.verdict.same{color:var(--muted)}.card:has(.compare){overflow-x:auto}.figure{margin:0;display:grid;gap:8px;overflow-x:auto}svg.chart{width:100%;min-width:560px;height:auto;display:block}.chart .band{fill:var(--accent-soft);opacity:.7}.chart .grid{stroke:var(--border);stroke-width:1}.chart .grid.faint{stroke-dasharray:2 4;opacity:.6}.chart .tick{fill:var(--muted);font:11px var(--sans)}.chart .target{stroke:var(--accent-strong);stroke-width:1;stroke-dasharray:5 4}.chart .line{fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}.chart .line.before,.chart .dot.before{stroke:var(--before);fill:none;opacity:.9}.chart .line.after{stroke:var(--accent);stroke-width:2.2}.chart .dot.before{fill:var(--before)}.chart .dot.after{fill:var(--accent)}.chart .cursor{stroke:var(--muted);stroke-width:1}.chart .overlay{fill:transparent;cursor:crosshair}.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin:0;padding:0;list-style:none;font-size:.8125rem;color:var(--muted)}.legend li{display:inline-flex;align-items:center;gap:6px}.swatch{display:inline-block;width:18px;height:3px;border-radius:2px}.swatch.before{background:var(--before)}.swatch.after{background:var(--accent);height:4px}.swatch.band{background:var(--accent-soft);height:10px;border:1px solid var(--accent)}.swatch.target{height:0;border-top:2px dashed var(--accent-strong);background:none}.readout{font-size:.8125rem;color:var(--muted);min-height:1.4em;font-variant-numeric:tabular-nums}.terms{display:grid;gap:10px}.term{display:grid;grid-template-columns:8.5rem minmax(0,1fr) auto;align-items:center;gap:12px;font-size:.875rem}.term-bars{display:grid;gap:3px}.track{display:block;height:7px;background:var(--surface-2);border-radius:4px;overflow:hidden}.fill{display:block;height:100%;border-radius:4px}.fill.before{background:var(--before)}.fill.after{background:var(--accent)}.term-values{font-size:.75rem;color:var(--muted);text-align:right;white-space:nowrap}@media(max-width:560px){.term{grid-template-columns:1fr;gap:4px}.term-values{text-align:left}.weights{grid-template-columns:1fr}}.run-info{text-align:center}.noscript{padding:16px;text-align:center} diff --git a/src/codonopt/web/app.js b/src/codonopt/web/app.js new file mode 100644 index 0000000..b87c020 --- /dev/null +++ b/src/codonopt/web/app.js @@ -0,0 +1,5 @@ +"use strict";(()=>{var P=class extends Error{constructor(n,o){super(n);this.status=o;this.name="ApiError"}},Me="X-Codonopt-Token",I=class{constructor(e){this.token=e}meta(){return this.request("GET","/api/meta")}start(e){return this.request("POST","/api/jobs",e)}poll(e){return this.request("GET",`/api/jobs/${encodeURIComponent(e)}`)}cancel(e){return this.request("POST",`/api/jobs/${encodeURIComponent(e)}/cancel`)}async request(e,n,o){let a;try{a=await fetch(n,{method:e,headers:{[Me]:this.token,...o===void 0?{}:{"Content-Type":"application/json"}},body:o===void 0?void 0:JSON.stringify(o),cache:"no-store"})}catch{throw new P("Cannot reach the codonopt server. Was the window or terminal that started it closed?",0)}let i=null;try{i=await a.json()}catch{}if(!a.ok){let r=i&&typeof i=="object"&&"error"in i?String(i.error):`The server answered ${a.status}.`;throw new P(r,a.status)}if(i===null||typeof i!="object")throw new P(`The server answered ${a.status} but sent no readable data.`,a.status);return i}};var Le=new Set("ACDEFGHIKLMNPQRSTVWY*"),re=new Set("ACGTU");function ce(t,e,n,o=!1){let a=t.trim();if(!a)return{state:"empty",message:"Nothing pasted yet.",kind:null,length:0};let i=a;if(a.startsWith(">")){let b=a.split(/\r?\n/),d=b.filter(w=>w.startsWith(">")).length;if(d>1)return q(`${d} FASTA records found. Paste one sequence at a time.`);i=b.slice(1).join("")}let r=i.replace(/\s+/g,"").toUpperCase();if(!r)return q("The FASTA header has no sequence under it.");let h=r.length>=9&&[...r].every(b=>re.has(b));if((e==="auto"?h?"dna":"protein":e)==="dna"){let b=le([...r].filter(w=>!re.has(w)));if(b.length)return q(`Not DNA: ${b.join(", ")}`);if(r.length%3)return q(`DNA \xB7 ${r.length} bases is not a whole number of codons (${r.length%3} extra).`);let d=r.length/3;return d>n?q(`DNA \xB7 ${d.toLocaleString()} codons; this page takes up to ${n.toLocaleString()}.`):ie("dna",r.length,`DNA \xB7 ${r.length.toLocaleString()} bases, ${d} codons`)}let m=le([...r].filter(b=>!Le.has(b)));if(m.length)return q(`Not a protein letter: ${m.join(", ")}`);let p=r.indexOf("*");if(p!==-1&&p!==r.length-1)return q(`Protein \xB7 stop symbol (*) at position ${p+1}, before the end.`);let L=r.length+(o&&!r.endsWith("*")?1:0);if(L>n)return q(`Protein \xB7 ${L.toLocaleString()} residues${L>r.length?" with the stop codon":""}; this page takes up to ${n.toLocaleString()}.`);let f=r.length===1?"residue":"residues";return ie("protein",r.length,`Protein \xB7 ${r.length.toLocaleString()} ${f}`)}function ie(t,e,n){return{state:"ok",message:n,kind:t,length:e}}function q(t){return{state:"error",message:t,kind:null,length:0}}function le(t){return[...new Set(t)].sort()}function V(t,e){for(let n of e)n==null||n===!1||t.appendChild(typeof n=="object"?n:document.createTextNode(String(n)))}function s(t,e,...n){let o=document.createElement(t);if(e){let{attrs:a,on:i,text:r,...h}=e;for(let[u,m]of Object.entries(h))m===void 0||m===!1||(u==="class"?o.className=String(m):typeof m=="boolean"?o.setAttribute(u,""):o.setAttribute(u,String(m)));for(let[u,m]of Object.entries(a??{}))o.setAttribute(u,m);if(r!==void 0&&(o.textContent=r),i)for(let[u,m]of Object.entries(i))o.addEventListener(u,m)}return V(o,n),o}var _e="http://www.w3.org/2000/svg";function E(t,e,...n){let o=document.createElementNS(_e,t);for(let[a,i]of Object.entries(e??{}))o.setAttribute(a,String(i));return V(o,n),o}function c(t){let e=document.getElementById(t);if(!e)throw new Error(`Missing #${t} in the page`);return e}function k(t,...e){t.replaceChildren(),V(t,e)}var x=(t,e=2)=>Number.isFinite(t)?t.toFixed(e):"\u2013",T=t=>Math.round(t).toLocaleString("en-US");var Se={gc_global:"Overall GC",gc_window:"Local GC",repeat:"Repeats",hairpin:"Hairpins",homopolymer:"Single-base runs",motif:"Forbidden sites",splice:"Splice sites",usage:"Codon usage"},ke={golden_gate:"Golden Gate",common_cloning:"Common cloning sites",regulatory:"Poly(A) and TATA signals"},$e={auto:"Automatic",maxent:"MaxEntScan",motif:"Consensus screen",none:"Off"},M=t=>c(t);function de(t){c("version").textContent=`v${t.version}`;let e=c("table");k(e,...t.tables.map(n=>s("option",{value:n.name,text:n.name})),s("option",{value:"custom",text:"Custom table\u2026"})),k(c("avoid-groups"),...Object.entries(t.motif_groups).filter(([,n])=>n.length).map(([n,o])=>s("label",{class:"chip-check",title:o.join(", ")},s("input",{type:"checkbox",value:n,attrs:{name:"avoid"}}),s("span",{text:ke[n]??n})))),k(c("splice"),...t.splice_backends.map(n=>s("option",{value:n,text:$e[n]??n}))),k(c("weights"),...Object.keys(t.weights).map(n=>s("label",{class:"field"},s("span",{text:Se[n]??n}),s("input",{type:"number",id:`weight-${n}`,attrs:{min:"0",step:"any",inputmode:"decimal","data-weight":n}})))),X(t)}function X(t){let e=t.defaults;c("table").value=t.default_table,c("table-text").value="",Y();for(let n of document.querySelectorAll('#avoid-groups input[type="checkbox"]'))n.checked=!1;c("avoid-extra").value="",M("gc-target").value=String(e.gc_target),M("gc-low").value=String(e.gc_range[0]),M("gc-high").value=String(e.gc_range[1]),M("max-homopolymer").value=String(e.max_homopolymer),c("splice").value="auto",c("effort-standard").checked=!0,M("seconds").value=String(e.seconds),M("seed").value="",M("gc-window").value=String(e.gc_window),M("gc-step").value=String(e.gc_step),M("repeat-k").value=String(e.repeat_k),M("hairpin-stem").value=String(e.hairpin_stem),M("hairpin-min-loop").value=String(e.hairpin_min_loop),M("hairpin-max-loop").value=String(e.hairpin_max_loop);for(let[n,o]of Object.entries(t.weights))M(`weight-${n}`).value=String(o);Q(t),Z(t)}function Y(){let t=c("table").value==="custom";c("custom-table").hidden=!t}function ue(){return c("table").value!=="custom"||c("table-text").value.trim()?null:"Paste or open a codon usage table, or pick a built-in host instead of \u201CCustom table\u2026\u201D."}function B(t){let e=c("table").value;c("table-hint").textContent=e==="custom"?"":t.tables.find(n=>n.name===e)?.description??""}function Q(t){let e=pe(),n=t.effort[e];if(!n)return;let o=[`${n.max_sweeps} greedy passes`];n.restarts>1&&o.push(`${n.restarts} independent restarts`),n.anneal_steps&&o.push(`${n.anneal_steps.toLocaleString()} annealing steps each`),c("effort-hint").textContent=`${o.join(", ")}.`}function Z(t){let e=c("splice").value,n=c("splice-hint");e==="maxent"&&!t.maxent_available?n.textContent="MaxEntScan is not installed, so this will report an error. Pick Automatic or Consensus screen.":e==="auto"?n.textContent=t.maxent_available?"Uses MaxEntScan.":"MaxEntScan is not installed, so a simpler consensus screen is used.":e==="motif"?n.textContent="A fast consensus screen. It flags likely sites rather than modelling splicing.":e==="none"?n.textContent="Splice sites are ignored. Right for sequences that will not be spliced, and faster.":n.textContent=""}function pe(){return document.querySelector('input[name="effort"]:checked')?.value??"standard"}function ee(){return document.querySelector('input[name="kind"]:checked')?.value??"auto"}function S(t){let e=M(t).value.trim();if(e==="")return;let n=Number(e);return Number.isFinite(n)?n:e}function he(){let t=[...document.querySelectorAll("#avoid-groups input:checked")].map(a=>a.value);t.push(...c("avoid-extra").value.split(/[,\n]/).map(a=>a.trim()).filter(Boolean));let e={};for(let a of document.querySelectorAll("#weights input")){let i=a.value.trim();i!==""&&(e[a.dataset.weight??""]=Number.isFinite(Number(i))?Number(i):i)}let n=c("table").value,o=ee();return{sequence:c("sequence").value,kind:o,add_stop:c("add-stop").checked,table:n==="custom"?void 0:n,table_text:n==="custom"?c("table-text").value:void 0,avoid:t,gc_target:S("gc-target"),gc_range:[S("gc-low"),S("gc-high")],max_homopolymer:S("max-homopolymer"),splice_backend:c("splice").value,effort:pe(),seconds:S("seconds"),seed:S("seed"),gc_window:S("gc-window"),gc_step:S("gc-step"),repeat_k:S("repeat-k"),hairpin_stem:S("hairpin-stem"),hairpin_min_loop:S("hairpin-min-loop"),hairpin_max_loop:S("hairpin-max-loop"),weights:e}}var O=720,ne=236,v={left:46,right:14,top:12,bottom:34},G=O-v.left-v.right,D=ne-v.top-v.bottom,te=[50,100,200,250,500,1e3,2e3,2500,5e3,1e4,2e4,5e4];function Ce(t){return te.find(e=>t/e<=8)??te[te.length-1]}function me(t,e){let n=Math.max(e,1),o=d=>v.left+d/n*G,a=d=>v.top+(1-d)*D,i=d=>Math.min(d+t.window/2,n),r=d=>d.map((w,_)=>`${_?"L":"M"}${o(i(t.starts[_])).toFixed(1)} ${a(w).toFixed(1)}`).join(""),h=E("svg",{viewBox:`0 0 ${O} ${ne}`,role:"img",class:"chart","aria-label":`GC content in ${t.window} base windows along the sequence, before and after optimisation, against the allowed range of ${x(t.range[0]*100,0)} to ${x(t.range[1]*100,0)} percent.`});h.append(E("rect",{class:"band",x:v.left,y:a(t.range[1]),width:G,height:Math.max(0,a(t.range[0])-a(t.range[1]))}));for(let d of[0,.25,.5,.75,1])h.append(E("line",{class:"grid",x1:v.left,x2:O-v.right,y1:a(d),y2:a(d)}),E("text",{class:"tick",x:v.left-8,y:a(d)+4,"text-anchor":"end"},`${d*100}%`));let u=Ce(n);for(let d=0;d<=n;d+=u)h.append(E("line",{class:"grid faint",x1:o(d),x2:o(d),y1:v.top,y2:v.top+D}),E("text",{class:"tick",x:o(d),y:ne-12,"text-anchor":"middle"},T(d)));h.append(E("line",{class:"target",x1:v.left,x2:O-v.right,y1:a(t.target),y2:a(t.target)})),t.starts.length>1?h.append(E("path",{class:"line before",d:r(t.initial)}),E("path",{class:"line after",d:r(t.final)})):t.starts.length===1&&h.append(E("circle",{class:"dot before",cx:o(i(t.starts[0])),cy:a(t.initial[0]),r:4}),E("circle",{class:"dot after",cx:o(i(t.starts[0])),cy:a(t.final[0]),r:4}));let m=E("line",{class:"cursor",x1:0,x2:0,y1:v.top,y2:v.top+D});m.setAttribute("visibility","hidden");let p=E("rect",{class:"overlay",x:v.left,y:v.top,width:G,height:D});h.append(m,p);let L="Hover the chart to read a window.",f=s("p",{class:"readout",text:L});p.addEventListener("pointermove",d=>{let w=h.getBoundingClientRect(),A=((d.clientX-w.left)/w.width*O-v.left)/G*n,y=0;for(let N=1;N{m.setAttribute("visibility","hidden"),f.textContent=L});let b=s("ul",{class:"legend"},s("li",null,s("span",{class:"swatch before"}),"Before"),s("li",null,s("span",{class:"swatch after"}),"After"),s("li",null,s("span",{class:"swatch band"}),"Allowed range"),s("li",null,s("span",{class:"swatch target"}),`Target ${x(t.target*100,0)}%`));return s("figure",{class:"figure"},h,b,f)}var fe=20;function ge(t){let e=t.sequence,n=t.initial_sequence,o=e.length/3,a=new Set;for(let f of t.motif_hits)for(let b of f.positions)for(let d=Math.floor(b/3);d<=Math.floor((b+f.length-1)/3);d++)a.add(d);let i=0,r=[],h=String(e.length).length;for(let f=0;fs("label",{class:"check"},s("input",{type:"checkbox",checked:d,on:{change:w=>u.classList.toggle(b,w.target.checked)}}),` ${f}`);!t.motif_hits.length&&!i&&u.classList.remove("show-changes");let p=i===0?"No codon needed to change.":`${T(i)} of ${T(o)} codons changed (${Math.round(i/o*100)}%).`,L=t.motif_hits.length?s("p",{class:"note warn",role:"status"},`Still contains ${t.motif_hits.map(f=>`${f.name} (${f.positions.length})`).join(", ")}; the marked codons overlap a site you asked to keep out.`):null;return s("div",{class:"seq-wrap"},s("div",{class:"seq-tools"},s("p",{class:"hint",text:p}),s("div",{class:"toggles"},m("Highlight changes","show-changes",u.classList.contains("show-changes")),m("Show protein","show-aa",!0))),L,u)}async function be(t){try{return await navigator.clipboard.writeText(t),!0}catch{let e=s("textarea",{attrs:{"aria-hidden":"true",tabindex:"-1"}});e.value=t,document.body.append(e),e.select();let n=!1;try{n=document.execCommand("copy")}catch{n=!1}return e.remove(),n}}function ve(t,e,n){let o=URL.createObjectURL(new Blob([e],{type:n})),a=s("a",{attrs:{href:o,download:t}});document.body.append(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(o),1e4)}function F(t,e,n=!1){let o=s("button",{type:"button",class:n?"chip primary-chip":"chip",text:t,on:{click:()=>e(o)}});return o}var se=new WeakMap;function we(t,e){let n=se.get(t);n!==void 0&&window.clearTimeout(n),t.dataset.label===void 0&&(t.dataset.label=t.textContent??""),t.textContent=e,se.set(t,window.setTimeout(()=>{t.textContent=t.dataset.label??"",se.delete(t)},1600))}var He=t=>t.replace(/[^\w.-]+/g,"_")||"sequence";function H(t,e){k(t,s("div",{class:"callout error",role:"alert"},s("strong",{text:"That did not work"}),s("p",{text:e}))),t.focus({preventScroll:!0})}var j=class{constructor(e){this.bar=s("div",{class:"bar-fill"});this.elapsed=s("span",{class:"mono"});this.detail=s("span",{class:"hint"});this.log=s("ol",{class:"log mono",attrs:{"aria-label":"Search log"}});this.trouble=s("p",{class:"note warn",role:"alert"});this.lastLog="";this.trouble.hidden=!0,k(e,s("div",{class:"card progress"},s("h2",{text:"Optimizing"}),s("p",{class:"hint",text:"Trying synonymous codons and keeping the changes that lower the score."}),s("div",{class:"bar",role:"progressbar",attrs:{"aria-label":"Optimization progress"}},this.bar),s("div",{class:"progress-meta"},this.elapsed,this.detail),this.trouble,this.log)),e.focus({preventScroll:!0})}note(e){this.trouble.textContent=e,this.trouble.hidden=!1}update(e){this.elapsed.textContent=`${x(e.elapsed,1)} s of at most ${T(e.max_seconds)} s`;let n=this.bar.parentElement;if(e.restarts_total>1){let a=Math.min(e.restarts_done,e.restarts_total);this.detail.textContent=`Restart ${Math.min(a+1,e.restarts_total)} of ${e.restarts_total}`,this.bar.style.width=`${a/e.restarts_total*100}%`,n?.classList.remove("indeterminate"),n?.setAttribute("aria-valuemin","0"),n?.setAttribute("aria-valuemax",String(e.restarts_total)),n?.setAttribute("aria-valuenow",String(a))}else this.detail.textContent="Stops as soon as nothing improves.",n?.classList.add("indeterminate"),n?.removeAttribute("aria-valuenow");let o=e.messages.join(` +`);o!==this.lastLog&&(this.lastLog=o,k(this.log,...e.messages.map(a=>s("li",{text:a}))))}},J=t=>`${x(t*100,1)}%`;function Ae(t){let e=t.splice_backend!==void 0;return[{label:"GC content",help:"Share of G and C bases over the whole sequence.",get:n=>n.gc_fraction,format:J,better:"target"},{label:"GC windows out of range",help:"Windows whose local GC falls outside the allowed range.",get:n=>n.gc_windows_below_range+n.gc_windows_above_range,better:"lower"},{label:"Codon adaptation index",help:"How closely the codons follow the host's preferences (1 is ideal).",get:n=>n.cai,format:n=>x(n,3),better:"higher"},{label:`Repeated ${t.repeat_k}-mers`,help:"Extra copies of any stretch that appears more than once. Repeats misassemble during synthesis.",get:n=>n.redundant_kmers,better:"lower"},{label:"Hairpin pairs",help:"Pairs of inverted repeats that can fold into stem-loops.",get:n=>n.hairpin_pairs,better:"lower"},{label:"Longest single-base run",help:"The longest stretch of one repeated base.",get:n=>n.longest_homopolymer,better:"lower"},{label:"Forbidden sites",help:"Matches to the enzymes and patterns you asked to keep out.",get:n=>n.forbidden_motif_hits,better:"lower"},{label:"Splice donor sites",help:"Windows that look like a 5\u2032 splice site.",get:n=>n.splice_donor_sites??0,better:"lower",show:()=>e},{label:"Splice acceptor sites",help:"Windows that look like a 3\u2032 splice site.",get:n=>n.splice_acceptor_sites??0,better:"lower",show:()=>e}]}function xe(t){return t.gc_windows_below_range+t.gc_windows_above_range+t.redundant_kmers+t.hairpin_pairs+t.homopolymer_excess_bases+t.forbidden_motif_hits+(t.splice_donor_sites??0)+(t.splice_acceptor_sites??0)}function Re(t){let e=t.gc.target,n=Ae(t.final).filter(o=>!o.show||o.show(t.final)).map(o=>{let a=o.get(t.initial),i=o.get(t.final),r=o.format??T,h;if(o.better==="target"){let p=Math.abs(i-e)-Math.abs(a-e);h=Math.abs(p)<5e-4?"same":p<0?"good":"bad"}else{let p=i-a;h=Math.abs(p)<1e-9?"same":p<0==(o.better==="lower")?"good":"bad"}let u={good:"better",bad:"worse",same:"no change"}[h],m={good:"\u2713",bad:"!",same:"\u2013"}[h];return s("tr",null,s("th",{attrs:{scope:"row",title:o.help}},o.label),s("td",{class:"num",text:r(a)}),s("td",{class:"num strong",text:r(i)}),s("td",{class:`verdict ${h}`},s("span",{attrs:{"aria-hidden":"true"}},`${m} `),u))});return s("table",{class:"compare"},s("thead",null,s("tr",null,s("th",{attrs:{scope:"col"}},"Property"),s("th",{class:"num",attrs:{scope:"col"}},"Before"),s("th",{class:"num",attrs:{scope:"col"}},"After"),s("th",{attrs:{scope:"col"}},"Change"))),s("tbody",null,...n))}var oe={gc_global:["Overall GC","Distance of the whole-sequence GC from the target."],gc_window:["Local GC","How far windows stray outside the allowed range."],repeat:["Repeats","Redundant copies of repeated stretches."],hairpin:["Hairpins","Inverted repeats that can fold back on themselves."],homopolymer:["Single-base runs","Bases beyond the longest run you allow."],motif:["Forbidden sites","Matches to enzymes and patterns you keep out."],splice:["Splice sites","How far splice-site scores exceed their cutoffs."],usage:["Codon usage","Cost of using codons the host dislikes."]};function qe(t){let e=t.score.initial_terms,n=t.score.final_terms,o=Object.keys(oe).filter(r=>(e[r]??0)>1e-9||(n[r]??0)>1e-9);if(!o.length)return s("p",{class:"hint",text:"No penalties at all: every constraint is met and codon usage is ideal."});let a=Math.max(...o.flatMap(r=>[e[r]??0,n[r]??0]),1e-9),i=(r,h)=>{let u=s("span",{class:`fill ${h}`});return u.style.width=`${Math.max(r>0?1.5:0,r/a*100)}%`,s("span",{class:"track"},u)};return s("div",{class:"terms"},...o.map(r=>s("div",{class:"term",title:oe[r][1]},s("span",{class:"term-name",text:oe[r][0]}),s("span",{class:"term-bars"},i(e[r]??0,"before"),i(n[r]??0,"after")),s("span",{class:"term-values mono",text:`${x(e[r]??0,2)} \u2192 ${x(n[r]??0,2)}`}))))}function K(t,e,...n){return s("section",{class:"card"},s("h3",{text:t}),e?s("p",{class:"hint",text:e}):null,...n)}function W(t,e,n,o){return s("div",{class:"stat"},s("span",{class:"stat-label",text:t}),s("span",{class:"stat-value"},s("span",{class:"was",text:e})," \u2192 ",s("strong",{text:n})),s("span",{class:"stat-note",text:o}))}function ye(t,e,n){let o=xe(e.initial),a=xe(e.final),i=e.score.initial>0?`${Math.round((1-e.score.final/e.score.initial)*100)}% lower score`:"score unchanged",r=He(e.name),h=e.cancelled?s("div",{class:"callout warn",role:"status"},"Stopped early. This is the best sequence found so far."):e.search.hit_time_limit?s("div",{class:"callout warn",role:"status"},"The time limit ended the search before it settled. Raise the limit or use a thorough search for more."):null,u=s("div",{class:"toolbar"},F("Copy DNA",m=>{be(e.sequence).then(p=>we(m,p?"Copied":"Copy failed"))},!0),F("Copy FASTA",m=>{be(e.fasta).then(p=>we(m,p?"Copied":"Copy failed"))}),F("Download FASTA",()=>ve(`${r}.codonopt.fna`,e.fasta,"text/plain")),F("Download report",()=>ve(`${r}.codonopt.json`,JSON.stringify(e,null,2)+` +`,"application/json")));k(t,s("div",{class:"result-head"},s("div",null,s("h2",{text:"Optimized sequence"}),s("p",{class:"hint",text:`${e.name} \xB7 ${e.protein.length.toLocaleString()} residues \xB7 ${e.codon_usage_table} codon usage`})),u),h,s("div",{class:"stats"},W("Issues found",T(o),T(a),a===0?"none left":"still to resolve"),W("Score",x(e.score.initial,2),x(e.score.final,2),i),W("GC content",J(e.initial.gc_fraction),J(e.final.gc_fraction),`target ${J(e.gc.target)}`),W("Codon adaptation",x(e.initial.cai,2),x(e.final.cai,2),"1.0 is ideal")),K("DNA sequence","The protein is identical to your input; only synonymous codons changed.",ge(e)),K("Before and after",null,Re(e)),K("GC content along the sequence",`Each point is a ${e.gc.window} base window.`,me(e.gc,e.sequence.length)),K("Where the score comes from","Lower is better. Each term is weighted as in your settings.",qe(e)),s("p",{class:"run-info hint"},`${e.search.restarts_run} ${e.search.restarts_run===1?"restart":"restarts"} \xB7 ${T(e.search.greedy_sweeps)} sweeps \xB7 ${T(e.search.moves_evaluated)} codon swaps tried \xB7 ${x(e.search.seconds,1)} s`)),t.focus({preventScroll:!0})}var Ne=350;async function Pe(){let t=document.querySelector('meta[name="codonopt-token"]')?.content??"",e=new I(t),n=c("output"),o=c("form"),a;try{a=await e.meta()}catch(l){H(n,l instanceof Error?l.message:String(l));return}de(a),B(a),o.removeAttribute("inert"),o.removeAttribute("aria-busy");let i=c("sequence"),r=c("detected"),h=c("run"),u=c("cancel"),m=c("add-stop-wrap"),p=null,L=!1,f=()=>{let l=ce(i.value,ee(),a.limits.max_residues,c("add-stop").checked);r.textContent=l.message,r.className=`detected ${l.state}`,m.hidden=l.kind==="dna",h.disabled=p!==null||L||l.state==="empty"};i.addEventListener("input",f),c("add-stop").addEventListener("change",f);for(let l of document.querySelectorAll('input[name="kind"]'))l.addEventListener("change",f);let b=8e6,d=async(l,g)=>{if(!l)return;if(l.size>b){H(n,`${l.name} is ${Math.round(l.size/1e6)} MB; open a file under 8 MB.`);return}let R;try{R=await l.text()}catch{H(n,`Could not read ${l.name||"that file"}. Open a text file, not a folder.`);return}g.value=R,g.dispatchEvent(new Event("input",{bubbles:!0}))},w=c("file-input");c("btn-open").addEventListener("click",()=>w.click()),w.addEventListener("change",()=>{d(w.files?.[0],i),w.value=""}),i.addEventListener("dragover",l=>l.preventDefault()),i.addEventListener("drop",l=>{l.preventDefault(),d(l.dataTransfer?.files[0],i)}),c("btn-example").addEventListener("click",()=>{i.value=`>${a.example.name} +${a.example.protein} +`,c("kind-auto").checked=!0,f(),i.focus()}),c("btn-clear").addEventListener("click",()=>{i.value="",f(),i.focus()}),c("table").addEventListener("change",()=>{Y(),B(a)});let A=c("table-text"),y=c("table-file");c("btn-open-table").addEventListener("click",()=>y.click()),y.addEventListener("change",()=>{d(y.files?.[0],A),y.value=""}),c("splice").addEventListener("change",()=>Z(a));for(let l of document.querySelectorAll('input[name="effort"]'))l.addEventListener("change",()=>Q(a));c("btn-reset").addEventListener("click",()=>{X(a),B(a)});let $=l=>{u.hidden=!l,u.disabled=!1,u.textContent="Stop",h.hidden=l,f()},C=l=>{c("status").textContent=l},N=l=>{window.clearInterval(l.timer),p===l&&(p=null,$(!1))},Ee=(l,g)=>{let R=p===l;N(l),R&&(g.status==="error"?(C("The optimization failed."),H(n,g.error??"The optimization failed.")):g.result&&(C(g.status==="cancelled"?"Stopped early; showing the best sequence found.":"Optimization finished."),ye(n,g.result,a)))},Te=async l=>{if(p===l)try{let g=await e.poll(l.id);if(p!==l)return;g.status==="running"?l.progress.update(g):Ee(l,g)}catch(g){if(p!==l)return;N(l),H(n,g instanceof Error?g.message:String(g))}};o.addEventListener("submit",async l=>{if(l.preventDefault(),p||L||h.disabled)return;let g=ue();if(g){H(n,g);return}L=!0,h.disabled=!0;let R;try{R=await e.start(he())}catch(U){H(n,U instanceof P?U.message:String(U));return}finally{L=!1,f()}C("Optimizing.");let ae=new j(n);ae.update(R);let z={id:R.id,timer:0,progress:ae};p=z,$(!0),z.timer=window.setInterval(()=>{Te(z)},Ne)}),u.addEventListener("click",async()=>{let l=p;if(l){u.disabled=!0,u.textContent="Stopping\u2026";try{await e.cancel(l.id)}catch(g){if(p!==l)return;u.disabled=!1,u.textContent="Stop",l.progress.note(`${g instanceof Error?g.message:String(g)} The run is still going; try Stop again.`)}}}),f()}Pe().catch(t=>{let e=document.getElementById("output"),n=t instanceof Error?t.message:String(t);e?H(e,`The interface failed to start: ${n}`):document.title="codonopt - failed to start",console.error(t)});})(); diff --git a/src/codonopt/web/favicon.svg b/src/codonopt/web/favicon.svg new file mode 100644 index 0000000..1428b17 --- /dev/null +++ b/src/codonopt/web/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/codonopt/web/index.html b/src/codonopt/web/index.html new file mode 100644 index 0000000..80a3390 --- /dev/null +++ b/src/codonopt/web/index.html @@ -0,0 +1,194 @@ + + + + + + + + codonopt + + + + + +
+
+ +
+

codonopt

+

Codon optimization for synthesis and expression

+
+
+ +
+ +
+ +
+
+ +
+
+

Sequence

+
+ + + +
+
+ + + +

Nothing pasted yet.

+
+
+ + + + + + +
+ +
+
+ +
+

Host

+ +

+ +
+ +
+

Keep out

+
+ +

Enzyme names or IUPAC patterns, comma separated. Both strands are checked.

+
+ +
+

Targets

+
+ + + +
+

GC as a fraction between 0 and 1. Every 30 base window is kept inside the min–max range.

+
+ + +
+

+
+ +
+

Search

+
+ + + + + + +
+

+
+ + +
+
+ +
+

Advanced

+

Each term of the score has a weight. Zero switches a term off.

+
+
+ + + + + + +
+ +
+ +
+ +
+ + +
+
+ + +

+
+
+

Design a sequence that is easy to make

+

The protein stays exactly the same. Only the synonymous codons change, so the DNA + avoids what makes genes hard to synthesise and express.

+
    +
  1. Paste a protein, or a DNA sequence you want to improve.
  2. +
  3. Choose a host and the sites to keep out.
  4. +
  5. Optimize, then compare before and after.
  6. +
+

Nothing leaves this computer: the page talks only to the local codonopt process.

+
+
+
+ + + + diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 15e9e45..81cd1e4 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -153,3 +153,17 @@ def test_find_hairpins_respects_the_limit(): unit = "ACGTTGCACC" sequence = (unit + "TTTTT" + reverse_complement(unit) + "AAA") * 6 assert len(find_hairpins(sequence, stem=10, min_loop=3, max_loop=20, limit=3)) == 3 + + +def test_gc_profile_matches_the_windows_that_are_scored(): + from codonopt.metrics import gc_profile + + sequence = "A" * 30 + "G" * 30 + profile = gc_profile(sequence, window=30, step=15) + assert [start for start, _ in profile] == [0, 15, 30] + assert [round(value, 3) for _, value in profile] == [0.0, 0.5, 1.0] + report = gc_window_report(sequence, window=30, step=15, target_range=(0.4, 0.6)) + assert report.windows == len(profile) + assert report.min_fraction == min(value for _, value in profile) + assert report.max_fraction == max(value for _, value in profile) + assert gc_profile("") == [] diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 71dc3a1..3f127ab 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -179,3 +179,44 @@ def test_single_residue_protein_is_handled(config): result = optimize("M", config, OptimizerSettings(seed=20)) assert result.sequence == "ATG" assert result.score == pytest.approx(result.initial_score) + + +def test_should_stop_cancels_cleanly_and_keeps_the_protein(config): + protein = random_protein(200, seed=60) + settings = OptimizerSettings( + seed=21, max_sweeps=50, restarts=5, should_stop=lambda: True + ) + result = optimize(protein, config, settings) + assert result.cancelled is True + assert result.hit_time_limit is False + assert translate(result.sequence) == protein + assert result.restarts_run == 1 # the first restart always produces a result + + +def test_should_stop_can_flip_part_way_through(plain_config): + protein = random_protein(300, seed=61) + calls = {"n": 0} + + def stop_after_a_while() -> bool: + calls["n"] += 1 + return calls["n"] > 12 + + full = optimize(protein, plain_config, OptimizerSettings(seed=22, max_sweeps=6)) + cut = optimize( + protein, + plain_config, + OptimizerSettings(seed=22, max_sweeps=6, should_stop=stop_after_a_while), + ) + assert cut.cancelled + assert cut.moves_evaluated < full.moves_evaluated + assert cut.score <= cut.initial_score # whatever it got to is no worse + + +def test_a_run_that_is_not_stopped_is_not_marked_cancelled(config): + result = optimize( + random_protein(30, seed=62), + config, + OptimizerSettings(seed=23, max_sweeps=2, should_stop=lambda: False), + ) + assert result.cancelled is False + assert result.hit_time_limit is False diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..224c0fc --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,498 @@ +"""Tests for the local web server. + +These drive a real server on a free loopback port over HTTP, because the +properties that matter -- the token and Host checks, the job lifecycle, the +status codes -- live in the request handling rather than in any one function. +""" + +from __future__ import annotations + +import http.client +import json +import threading +import time + +import pytest + +from codonopt import translate +from codonopt.server import ( + MAX_RESIDUES, + RequestError, + build_request, + create_server, +) + +from conftest import random_protein + +TOKEN_HEADER = "X-Codonopt-Token" +PROTEIN = random_protein(40, seed=50) +FAST = {"splice_backend": "none", "effort": "quick", "seed": 1, "seconds": 30} + + +class Client: + """A small HTTP client for the test server.""" + + def __init__(self, server) -> None: + self.server = server + self.port = server.server_address[1] + self.token = server.app.token + + def call( + self, + method: str, + path: str, + body=None, + *, + token: str | None = "default", + host: str | None = "default", + origin: str | None = None, + raw: bytes | None = None, + content_length: str | None = None, + ): + connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30) + connection.putrequest(method, path, skip_host=True, skip_accept_encoding=True) + if host is not None: + connection.putheader( + "Host", f"127.0.0.1:{self.port}" if host == "default" else host + ) + if token == "default": + token = self.token + if token is not None: + connection.putheader(TOKEN_HEADER, token) + if origin is not None: + connection.putheader("Origin", origin) + payload = raw if raw is not None else ( + json.dumps(body).encode() if body is not None else b"" + ) + if method == "POST": + connection.putheader( + "Content-Length", + content_length if content_length is not None else str(len(payload)), + ) + connection.putheader("Content-Type", "application/json") + connection.endheaders(payload if method == "POST" and content_length is None else b"") + response = connection.getresponse() + data = response.read() + headers = {key.lower(): value for key, value in response.getheaders()} + connection.close() + try: + parsed = json.loads(data) + except (ValueError, UnicodeDecodeError): + parsed = data + return response.status, headers, parsed + + def start(self, payload: dict): + return self.call("POST", "/api/jobs", payload) + + def wait(self, job_id: str, timeout: float = 90.0) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + status, _, body = self.call("GET", f"/api/jobs/{job_id}") + assert status == 200, body + if body["status"] != "running": + return body + time.sleep(0.05) + raise AssertionError(f"job {job_id} did not finish in {timeout}s") + + +@pytest.fixture +def client(): + server = create_server() + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield Client(server) + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +# -- static serving and request hygiene -------------------------------------- + + +def test_index_is_served_with_the_session_token_and_a_strict_policy(client): + status, headers, body = client.call("GET", "/", token=None) + assert status == 200 + assert headers["content-type"].startswith("text/html") + assert client.token.encode() in body + assert b"__CODONOPT_TOKEN__" not in body + assert "default-src 'none'" in headers["content-security-policy"] + assert "script-src 'self'" in headers["content-security-policy"] + assert headers["cache-control"] == "no-store" + assert headers["x-content-type-options"] == "nosniff" + + +def test_each_server_has_its_own_token(): + first, second = create_server(), create_server() + try: + assert first.app.token != second.app.token + assert len(first.app.token) >= 24 + finally: + first.server_close() + second.server_close() + + +@pytest.mark.parametrize( + "path", + ["/nope", "/../pyproject.toml", "/web/../cli.py", "/%2e%2e/cli.py", "/app.js/../x"], +) +def test_unknown_and_traversal_paths_are_not_found(client, path): + status, _, _ = client.call("GET", path, token=None) + assert status == 404 + + +def test_static_paths_reject_other_methods(client): + status, _, _ = client.call("POST", "/", {}) + assert status == 405 + + +def test_foreign_host_header_is_refused(client): + """A DNS-rebinding page reaches this server under its own hostname.""" + status, _, _ = client.call("GET", "/", token=None, host="evil.example") + assert status == 403 + status, _, _ = client.call("GET", "/api/meta", host="evil.example") + assert status == 403 + status, _, _ = client.call("GET", "/", token=None, host=None) + assert status == 403 + + +def test_localhost_name_is_accepted(client): + status, _, _ = client.call("GET", "/api/meta", host=f"localhost:{client.port}") + assert status == 200 + + +def test_wrong_port_in_host_header_is_refused(client): + status, _, _ = client.call("GET", "/", token=None, host="127.0.0.1:1") + assert status == 403 + + +def test_api_requires_the_token(client): + assert client.call("GET", "/api/meta", token=None)[0] == 403 + assert client.call("GET", "/api/meta", token="wrong")[0] == 403 + assert client.call("POST", "/api/jobs", {"sequence": "MKV"}, token=None)[0] == 403 + + +def test_cross_origin_posts_are_refused(client): + status, _, body = client.call( + "POST", "/api/jobs", {"sequence": PROTEIN, **FAST}, origin="https://evil.example" + ) + assert status == 403 + status, _, body = client.call( + "POST", + "/api/jobs", + {"sequence": PROTEIN, **FAST}, + origin=f"http://127.0.0.1:{client.port}", + ) + assert status == 202 + + +def test_only_loopback_addresses_can_be_bound(): + for host in ("0.0.0.0", "192.168.1.5", "example.com", ""): + with pytest.raises(ValueError, match="loopback"): + create_server(host) + + +# -- meta -------------------------------------------------------------------- + + +def test_meta_describes_the_form(client): + status, _, meta = client.call("GET", "/api/meta") + assert status == 200 + assert {table["name"] for table in meta["tables"]} >= {"human", "ecoli", "uniform"} + assert "BsaI" in meta["motif_groups"]["golden_gate"] + assert meta["restriction_sites"]["EcoRI"] == "GAATTC" + assert meta["defaults"]["gc_range"] == [0.3, 0.7] + assert set(meta["weights"]) >= {"gc_global", "repeat", "motif", "usage"} + assert set(meta["effort"]) == {"quick", "standard", "thorough"} + assert isinstance(meta["maxent_available"], bool) + assert meta["example"]["protein"].startswith("M") + assert meta["limits"]["max_residues"] == MAX_RESIDUES + + +# -- job lifecycle ----------------------------------------------------------- + + +def test_a_job_runs_to_completion_and_keeps_the_protein(client): + status, _, started = client.start( + {"sequence": PROTEIN, "avoid": ["golden_gate", "EcoRI"], **FAST} + ) + assert status == 202 + assert started["status"] == "running" + + done = client.wait(started["id"]) + assert done["status"] == "done" + result = done["result"] + assert translate(result["sequence"]) == PROTEIN + assert result["protein"] == PROTEIN + assert translate(result["initial_sequence"]) == PROTEIN + assert result["score"]["final"] <= result["score"]["initial"] + assert result["final"]["forbidden_motif_hits"] == 0 + assert result["fasta"].startswith(">sequence ") + assert result["fasta"].replace("\n", "").endswith(result["sequence"]) + assert result["cancelled"] is False + + +def test_the_gc_profile_lines_up_for_plotting(client): + _, _, started = client.start({"sequence": PROTEIN, **FAST}) + gc = client.wait(started["id"])["result"]["gc"] + assert len(gc["starts"]) == len(gc["initial"]) == len(gc["final"]) > 0 + assert gc["range"] == [0.3, 0.7] + assert all(0.0 <= value <= 1.0 for value in gc["final"]) + assert gc["starts"] == sorted(gc["starts"]) + + +def test_progress_is_reported_while_running(client): + _, _, started = client.start( + {"sequence": random_protein(120, seed=51), **FAST, "effort": "thorough"} + ) + snapshot = client.call("GET", f"/api/jobs/{started['id']}")[2] + assert snapshot["restarts_total"] == 3 + assert snapshot["max_seconds"] == 30 + done = client.wait(started["id"]) + assert done["restarts_done"] == 3 + assert any("final score" in message for message in done["messages"]) + + +def test_dna_is_detected_and_improved_in_place(client): + dna = "ATGAAATGGGTTACCTTTATCTCTCTGCTGTTG" + _, _, started = client.start({"sequence": f">gene one\n{dna}\n", **FAST}) + result = client.wait(started["id"])["result"] + assert result["kind"] == "dna" + assert result["name"] == "gene" + assert result["initial_sequence"] == dna + assert translate(result["sequence"]) == translate(dna) + + +def test_a_trailing_stop_codon_can_be_requested(client): + _, _, started = client.start({"sequence": "MKWVTF", "add_stop": True, **FAST}) + result = client.wait(started["id"])["result"] + assert result["protein"] == "MKWVTF*" + assert translate(result["sequence"]) == "MKWVTF*" + + +def test_a_custom_codon_table_can_be_pasted(client): + table = json.dumps({"CTG": 1, "CTC": 0.1}) + _, _, started = client.start( + {"sequence": "MLLLLL", "table_text": table, **FAST} + ) + result = client.wait(started["id"])["result"] + assert result["codon_usage_table"] == "custom" + + +def test_remaining_motif_hits_are_reported_for_highlighting(client): + # Weight the motif term to zero so a site is allowed to survive. + _, _, started = client.start( + { + "sequence": "MKWVTF", + "avoid": ["x=ATG"], + "weights": {"motif": 0}, + **FAST, + } + ) + hits = client.wait(started["id"])["result"]["motif_hits"] + assert hits and hits[0]["name"] == "x" and hits[0]["length"] == 3 + assert hits[0]["positions"][0] == 0 + + +def test_cancelling_returns_the_best_sequence_so_far(client): + protein = random_protein(500, seed=52) + _, _, started = client.start( + {"sequence": protein, **FAST, "effort": "thorough", "seconds": 300} + ) + begun = time.monotonic() + status, _, _ = client.call("POST", f"/api/jobs/{started['id']}/cancel") + assert status == 200 + done = client.wait(started["id"], timeout=60) + assert time.monotonic() - begun < 30 # nowhere near the 300 s budget + assert done["status"] == "cancelled" + assert translate(done["result"]["sequence"]) == protein + assert done["result"]["cancelled"] is True + + +def test_the_concurrency_limit_is_enforced_and_recovers(client): + protein = random_protein(600, seed=53) + slow = {"sequence": protein, **FAST, "effort": "thorough", "seconds": 300} + ids = [] + for _ in range(2): + status, _, body = client.start(slow) + assert status == 202 + ids.append(body["id"]) + status, _, body = client.start(slow) + assert status == 400 + assert "as many optimisations as allowed" in body["error"] + + for job_id in ids: + client.call("POST", f"/api/jobs/{job_id}/cancel") + client.wait(job_id, timeout=60) + assert client.start({"sequence": PROTEIN, **FAST})[0] == 202 + + +def test_unknown_jobs_are_not_found(client): + assert client.call("GET", "/api/jobs/nonexistent")[0] == 404 + assert client.call("POST", "/api/jobs/nonexistent/cancel")[0] == 404 + assert client.call("GET", "/api/elsewhere")[0] == 404 + + +# -- bad requests ------------------------------------------------------------ + + +@pytest.mark.parametrize( + "payload, message", + [ + ({"sequence": ""}, "Paste a protein or DNA sequence"), + ({"sequence": " \n "}, "Paste a protein or DNA sequence"), + ({"sequence": "MKZ"}, "unsupported residues"), + ({"sequence": "MK*V"}, "internal stop"), + ({"sequence": "ATGAAATGGG"}, "multiple of three"), + ({"sequence": ">a\nMKV\n>b\nMKW\n"}, "2 FASTA records"), + ({"sequence": "MKV", "gc_range": [30, 70]}, "between 0 and 1"), + ({"sequence": "MKV", "gc_range": [0.7, 0.3]}, "low <= high"), + ({"sequence": "MKV", "gc_range": [0.3]}, "two numbers"), + ({"sequence": "MKV", "gc_target": 2}, "between 0 and 1"), + ({"sequence": "MKV", "gc_window": 2.5}, "whole number"), + ({"sequence": "MKV", "gc_window": "wide"}, "must be a number"), + ({"sequence": "MKV", "table": "mouse"}, "unknown codon usage table"), + ({"sequence": "MKV", "table_text": "TTT,abc"}, "not a number"), + ({"sequence": "MKV", "table_text": "nothing useful"}, "expected two columns"), + ({"sequence": "MKV", "table_text": "codon,count\n"}, "no codon rows"), + ({"sequence": "MKV", "avoid": ["NotAnEnzyme"]}, "cannot resolve motif"), + ({"sequence": "MKV", "avoid": "EcoRI"}, "list of motif names"), + ({"sequence": "MKV", "weights": {"nonsense": 1}}, "unknown weight"), + ({"sequence": "MKV", "weights": {"usage": -1}}, "between 0 and"), + ({"sequence": "MKV", "weights": []}, "must be an object"), + ({"sequence": "MKV", "effort": "extreme"}, "effort must be"), + ({"sequence": "MKV", "splice_backend": "magic"}, "splice backend must be"), + ({"sequence": "MKV", "seconds": 0}, "between 1 and"), + ({"sequence": "MKV", "seconds": 10**6}, "between 1 and"), + ({"sequence": "MKV", "seed": -1}, "between 0 and"), + # An integer too wide for a float used to escape as a 500. + ({"sequence": "MKV", "seconds": 10**400}, "between 1 and"), + ({"sequence": "MKV", "gc_target": -(10**400)}, "between 0 and"), + # "Custom table" with nothing in it must not fall back to another host. + ({"sequence": "MKV", "table_text": ""}, "custom codon table is empty"), + ({"sequence": "MKV", "table_text": " "}, "custom codon table is empty"), + ({"sequence": "MKV", "kind": "rna"}, "kind must be"), + ({"sequence": "A" * (MAX_RESIDUES + 1), "kind": "protein"}, "accepts up to"), + ], +) +def test_bad_requests_get_a_readable_400(client, payload, message): + status, _, body = client.call("POST", "/api/jobs", payload) + assert status == 400, body + assert message in body["error"] + + +def test_malformed_bodies(client): + assert client.call("POST", "/api/jobs", raw=b"{not json")[0] == 400 + assert client.call("POST", "/api/jobs", raw=b"[1, 2]")[0] == 400 + assert client.call("POST", "/api/jobs", raw=b"\xff\xfe")[0] == 400 + # More digits than CPython will parse into an int: a bad request, not a crash. + huge = b'{"sequence": "MKV", "seconds": ' + b"9" * 5000 + b"}" + status, _, body = client.call("POST", "/api/jobs", raw=huge) + assert status == 400 + assert "not valid JSON" in body["error"] + + +def test_an_explicit_table_still_wins_over_an_empty_custom_one(client): + """The page omits `table` for a custom table; other callers may send both.""" + status, _, started = client.start( + {"sequence": "MKV", "table": "ecoli", "table_text": "", **FAST} + ) + assert status == 202 + assert client.wait(started["id"])["status"] == "done" + status, _, body = client.call("POST", "/api/jobs", raw=b"null") + assert status == 400 + + +def test_oversized_and_unsized_bodies_are_refused(client): + status, _, _ = client.call( + "POST", "/api/jobs", raw=b"", content_length=str(3_000_000) + ) + assert status == 413 + status, _, _ = client.call("POST", "/api/jobs", raw=b"", content_length="lots") + assert status == 411 + + +# -- build_request, without HTTP -------------------------------------------- + + +def test_build_request_interprets_the_input(): + spec = build_request({"sequence": "MKWVTF", "add_stop": True}) + assert (spec.protein, spec.start, spec.kind) == ("MKWVTF*", None, "protein") + + spec = build_request({"sequence": "atgaaatgggtt"}) + assert spec.kind == "dna" and spec.start == "ATGAAATGGGTT" + assert spec.protein == "MKWV" + + # RNA spelling is accepted and normalised. + spec = build_request({"sequence": "AUGAAAUGGGUU"}) + assert spec.start == "ATGAAATGGGTT" + + +def test_add_stop_does_not_alter_a_dna_input(): + spec = build_request({"sequence": "ATGAAATGGGTT", "add_stop": True}) + assert spec.protein == "MKWV" + + +def test_kind_overrides_detection(): + # Alanine, cysteine, glycine and threonine spell valid DNA letters, so a + # sequence of only those is ambiguous and the caller may need to say. + assert build_request({"sequence": "ACGTACGTA"}).kind == "dna" + spec = build_request({"sequence": "ACGTACGTA", "kind": "protein"}) + assert spec.kind == "protein" and spec.start is None + assert spec.protein == "ACGTACGTA" + # Short strings are read as protein unless told otherwise. + assert build_request({"sequence": "ATGAAATG"}).kind == "protein" + + +def test_build_request_names_the_record(): + assert build_request({"sequence": ">albumin sample\nMKV"}).name == "albumin" + assert build_request({"sequence": "MKV", "name": "mine"}).name == "mine" + assert build_request({"sequence": "MKV"}).name == "sequence" + + +def test_build_request_resolves_every_option(): + spec = build_request( + { + "sequence": "MKV", + "table": "ecoli", + "avoid": ["golden_gate", "AATAAA"], + "gc_target": 0.6, + "gc_range": [0.35, 0.65], + "repeat_k": 9, + "weights": {"usage": 0, "motif": 10}, + "effort": "thorough", + "seed": 7, + "seconds": 45, + "splice_backend": "motif", + } + ) + config = spec.config + assert config.table.name == "ecoli" + assert config.gc_target == 0.6 and config.gc_range == (0.35, 0.65) + assert config.repeat_k == 9 + assert config.weights.usage == 0 and config.weights.motif == 10 + assert len(config.motifs) == 5 # four golden-gate enzymes plus AATAAA + assert config.splice_scorer.name == "motif" + assert spec.settings["restarts"] == 3 and spec.settings["seed"] == 7 + assert spec.settings["max_seconds"] == 45 + + +def test_request_errors_are_value_errors(): + assert issubclass(RequestError, ValueError) + with pytest.raises(RequestError): + build_request("not an object") + + +# ---------------------------------------------------------------- built assets + + +def test_built_interface_is_shipped_and_wired_to_the_token(): + """The Node-free install serves committed files; make sure they exist and fit together.""" + from importlib import resources + + web = resources.files("codonopt") / "web" + names = {p.name for p in web.iterdir()} + assert {"index.html", "app.js", "app.css", "favicon.svg"} <= names + html = (web / "index.html").read_text(encoding="utf-8") + assert "__CODONOPT_TOKEN__" in html + assert "app.js" in html and "app.css" in html + assert " + + +
+
+ +
+

codonopt

+

Codon optimization for synthesis and expression

+
+
+ +
+ +
+ +
+
+ +
+
+

Sequence

+
+ + + +
+
+ + + +

Nothing pasted yet.

+
+
+ + + + + + +
+ +
+
+ +
+

Host

+ +

+ +
+ +
+

Keep out

+
+ +

Enzyme names or IUPAC patterns, comma separated. Both strands are checked.

+
+ +
+

Targets

+
+ + + +
+

GC as a fraction between 0 and 1. Every 30 base window is kept inside the min–max range.

+
+ + +
+

+
+ +
+

Search

+
+ + + + + + +
+

+
+ + +
+
+ +
+

Advanced

+

Each term of the score has a weight. Zero switches a term off.

+
+
+ + + + + + +
+ +
+ +
+ +
+ + +
+
+ + +

+
+
+

Design a sequence that is easy to make

+

The protein stays exactly the same. Only the synonymous codons change, so the DNA + avoids what makes genes hard to synthesise and express.

+
    +
  1. Paste a protein, or a DNA sequence you want to improve.
  2. +
  3. Choose a host and the sites to keep out.
  4. +
  5. Optimize, then compare before and after.
  6. +
+

Nothing leaves this computer: the page talks only to the local codonopt process.

+
+
+
+ + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..0bced6f --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,250 @@ +import { Api, ApiError } from "./api"; +import { describe } from "./detect"; +import { $ } from "./dom"; +import { + describeEffort, + describeSplice, + describeTable, + populate, + problem, + readPayload, + reset, + selectedKind, + syncTable, +} from "./form"; +import { Progress, showError, showResult } from "./results"; +import type { Job, Meta } from "./types"; + +const POLL_MS = 350; + +async function main(): Promise { + const token = document.querySelector('meta[name="codonopt-token"]')?.content ?? ""; + const api = new Api(token); + const output = $("output"); + + // The form starts inert: `populate` fills it from the server's defaults, and + // anything typed before that lands would be overwritten without warning. + const form = $("form"); + let meta: Meta; + try { + meta = await api.meta(); + } catch (error) { + showError(output, error instanceof Error ? error.message : String(error)); + return; + } + populate(meta); + describeTable(meta); + form.removeAttribute("inert"); + form.removeAttribute("aria-busy"); + + const sequence = $("sequence"); + const detected = $("detected"); + const run = $("run"); + const cancel = $("cancel"); + const stopRow = $("add-stop-wrap"); + /** The one run that owns the screen. Anything else that arrives is stale. */ + interface Run { + id: string; + timer: number; + progress: Progress; + } + let running: Run | null = null; + let starting = false; + + /* ----- sequence box ----- */ + + const refresh = (): void => { + const info = describe( + sequence.value, + selectedKind(), + meta.limits.max_residues, + $("add-stop").checked, + ); + detected.textContent = info.message; + detected.className = `detected ${info.state}`; + stopRow.hidden = info.kind === "dna"; + run.disabled = running !== null || starting || info.state === "empty"; + }; + sequence.addEventListener("input", refresh); + $("add-stop").addEventListener("change", refresh); + for (const radio of document.querySelectorAll('input[name="kind"]')) radio.addEventListener("change", refresh); + + // Generous next to the 15,000-residue limit, small enough that a misdropped + // genome cannot hang the tab. + const MAX_FILE_BYTES = 8_000_000; + + const load = async (file: File | undefined, into: HTMLTextAreaElement): Promise => { + if (!file) return; + if (file.size > MAX_FILE_BYTES) { + showError(output, `${file.name} is ${Math.round(file.size / 1e6)} MB; open a file under 8 MB.`); + return; + } + let text: string; + try { + text = await file.text(); + } catch { + // A folder dropped on the box, or a file that moved since it was picked. + showError(output, `Could not read ${file.name || "that file"}. Open a text file, not a folder.`); + return; + } + into.value = text; + into.dispatchEvent(new Event("input", { bubbles: true })); + }; + const fileInput = $("file-input"); + $("btn-open").addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", () => { + void load(fileInput.files?.[0], sequence); + fileInput.value = ""; + }); + sequence.addEventListener("dragover", (event) => event.preventDefault()); + sequence.addEventListener("drop", (event) => { + event.preventDefault(); + void load(event.dataTransfer?.files[0], sequence); + }); + + $("btn-example").addEventListener("click", () => { + sequence.value = `>${meta.example.name}\n${meta.example.protein}\n`; + $("kind-auto").checked = true; + refresh(); + sequence.focus(); + }); + $("btn-clear").addEventListener("click", () => { + sequence.value = ""; + refresh(); + sequence.focus(); + }); + + /* ----- settings ----- */ + + const tableSelect = $("table"); + tableSelect.addEventListener("change", () => { + syncTable(); + describeTable(meta); + }); + const tableText = $("table-text"); + const tableFile = $("table-file"); + $("btn-open-table").addEventListener("click", () => tableFile.click()); + tableFile.addEventListener("change", () => { + void load(tableFile.files?.[0], tableText); + tableFile.value = ""; + }); + $("splice").addEventListener("change", () => describeSplice(meta)); + for (const radio of document.querySelectorAll('input[name="effort"]')) { + radio.addEventListener("change", () => describeEffort(meta)); + } + $("btn-reset").addEventListener("click", () => { + reset(meta); + describeTable(meta); + }); + + /* ----- running ----- */ + + const setRunning = (active: boolean): void => { + cancel.hidden = !active; + cancel.disabled = false; + cancel.textContent = "Stop"; + run.hidden = active; + refresh(); + }; + + /** Announce a change a sighted user sees in the results region. */ + const say = (message: string): void => { + $("status").textContent = message; + }; + + /** Let go of a run: stop polling it, and clear the state if it is current. */ + const release = (job: Run): void => { + window.clearInterval(job.timer); + if (running !== job) return; // a later run owns the screen now + running = null; + setRunning(false); + }; + + const finish = (job: Run, latest: Job): void => { + const current = running === job; + release(job); + if (!current) return; // a stale poll must not paint over a newer run + if (latest.status === "error") { + say("The optimization failed."); + showError(output, latest.error ?? "The optimization failed."); + } else if (latest.result) { + say(latest.status === "cancelled" ? "Stopped early; showing the best sequence found." : "Optimization finished."); + showResult(output, latest.result, meta); + } + }; + + const poll = async (job: Run): Promise => { + if (running !== job) return; + try { + const latest = await api.poll(job.id); + if (running !== job) return; // the answer arrived after we moved on + if (latest.status === "running") job.progress.update(latest); + else finish(job, latest); + } catch (error) { + if (running !== job) return; + release(job); + showError(output, error instanceof Error ? error.message : String(error)); + } + }; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + // `starting` closes the window between this click and the server's reply, + // where a second click would otherwise start a second job. + if (running || starting || run.disabled) return; + const wrong = problem(); + if (wrong) { + showError(output, wrong); + return; + } + starting = true; + run.disabled = true; + let started: Job; + try { + started = await api.start(readPayload()); + } catch (error) { + showError(output, error instanceof ApiError ? error.message : String(error)); + return; + } finally { + starting = false; + refresh(); + } + say("Optimizing."); + const progress = new Progress(output); + progress.update(started); + const job: Run = { id: started.id, timer: 0, progress }; + running = job; + setRunning(true); + job.timer = window.setInterval(() => void poll(job), POLL_MS); + }); + + cancel.addEventListener("click", async () => { + const job = running; + if (!job) return; + cancel.disabled = true; + cancel.textContent = "Stopping…"; + try { + await api.cancel(job.id); + } catch (error) { + if (running !== job) return; + // Stop is the only way out of a long run, so never leave it dead. + cancel.disabled = false; + cancel.textContent = "Stop"; + job.progress.note( + `${error instanceof Error ? error.message : String(error)} The run is still going; try Stop again.`, + ); + } + }); + + refresh(); +} + +// Without this, any failure while wiring the page would leave it silent and +// dead, with no hint of what went wrong. +void main().catch((error: unknown) => { + const output = document.getElementById("output"); + const message = error instanceof Error ? error.message : String(error); + if (output) showError(output, `The interface failed to start: ${message}`); + else document.title = "codonopt - failed to start"; + console.error(error); +}); diff --git a/web/src/results.ts b/web/src/results.ts new file mode 100644 index 0000000..bda9635 --- /dev/null +++ b/web/src/results.ts @@ -0,0 +1,397 @@ +import { gcChart } from "./chart"; +import { fmt, h, int, replace } from "./dom"; +import type { Child } from "./dom"; +import { sequenceView } from "./sequence"; +import type { Job, Measures, Meta, Result } from "./types"; + +/* ---------- small helpers ---------- */ + +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Older embedded web views have no async clipboard: fall back to a hidden textarea. + const area = h("textarea", { attrs: { "aria-hidden": "true", tabindex: "-1" } }); + area.value = text; + document.body.append(area); + area.select(); + let done = false; + try { + done = document.execCommand("copy"); + } catch { + done = false; + } + area.remove(); + return done; + } +} + +function download(filename: string, text: string, type: string): void { + const url = URL.createObjectURL(new Blob([text], { type })); + const link = h("a", { attrs: { href: url, download: filename } }); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); +} + +function action(label: string, onClick: (button: HTMLButtonElement) => void, primary = false): HTMLButtonElement { + const button: HTMLButtonElement = h("button", { + type: "button", + class: primary ? "chip primary-chip" : "chip", + text: label, + on: { click: () => onClick(button) }, + }); + return button; +} + +const flashing = new WeakMap(); + +/** Briefly replace a button's label, surviving repeated clicks. */ +function flash(button: HTMLButtonElement, message: string): void { + const pending = flashing.get(button); + if (pending !== undefined) window.clearTimeout(pending); + // Capture the real label once; a second click must not capture "Copied". + if (button.dataset.label === undefined) button.dataset.label = button.textContent ?? ""; + button.textContent = message; + flashing.set( + button, + window.setTimeout(() => { + button.textContent = button.dataset.label ?? ""; + flashing.delete(button); + }, 1600), + ); +} + +const safeName = (name: string): string => name.replace(/[^\w.-]+/g, "_") || "sequence"; + +/* ---------- empty, running and error states ---------- */ + +export function showError(output: HTMLElement, message: string): void { + replace( + output, + h( + "div", + { class: "callout error", role: "alert" }, + h("strong", { text: "That did not work" }), + h("p", { text: message }), + ), + ); + output.focus({ preventScroll: true }); +} + +/** A progress card that is built once and updated in place on every poll. */ +export class Progress { + private readonly bar = h("div", { class: "bar-fill" }); + private readonly elapsed = h("span", { class: "mono" }); + private readonly detail = h("span", { class: "hint" }); + private readonly log = h("ol", { class: "log mono", attrs: { "aria-label": "Search log" } }); + private readonly trouble = h("p", { class: "note warn", role: "alert" }); + private lastLog = ""; + + constructor(output: HTMLElement) { + this.trouble.hidden = true; + replace( + output, + h( + "div", + { class: "card progress" }, + h("h2", { text: "Optimizing" }), + h("p", { class: "hint", text: "Trying synonymous codons and keeping the changes that lower the score." }), + h("div", { class: "bar", role: "progressbar", attrs: { "aria-label": "Optimization progress" } }, this.bar), + h("div", { class: "progress-meta" }, this.elapsed, this.detail), + this.trouble, + this.log, + ), + ); + output.focus({ preventScroll: true }); + } + + /** Report something that went wrong without throwing the run's state away. */ + note(message: string): void { + this.trouble.textContent = message; + this.trouble.hidden = false; + } + + update(job: Job): void { + this.elapsed.textContent = `${fmt(job.elapsed, 1)} s of at most ${int(job.max_seconds)} s`; + const track = this.bar.parentElement; + if (job.restarts_total > 1) { + const done = Math.min(job.restarts_done, job.restarts_total); + this.detail.textContent = `Restart ${Math.min(done + 1, job.restarts_total)} of ${job.restarts_total}`; + this.bar.style.width = `${(done / job.restarts_total) * 100}%`; + track?.classList.remove("indeterminate"); + track?.setAttribute("aria-valuemin", "0"); + track?.setAttribute("aria-valuemax", String(job.restarts_total)); + track?.setAttribute("aria-valuenow", String(done)); + } else { + this.detail.textContent = "Stops as soon as nothing improves."; + track?.classList.add("indeterminate"); + track?.removeAttribute("aria-valuenow"); + } + const key = job.messages.join("\n"); + if (key !== this.lastLog) { + this.lastLog = key; + replace(this.log, ...job.messages.map((message) => h("li", { text: message }))); + } + } +} + +/* ---------- the result ---------- */ + +interface Row { + label: string; + help: string; + get: (m: Measures) => number; + format?: (value: number) => string; + /** How to judge the change: fewer is better, more is better, or nearer the target. */ + better: "lower" | "higher" | "target"; + show?: (m: Measures) => boolean; +} + +const percent = (v: number): string => `${fmt(v * 100, 1)}%`; + +function rows(after: Measures): Row[] { + const hasSplice = after.splice_backend !== undefined; + return [ + { + label: "GC content", + help: "Share of G and C bases over the whole sequence.", + get: (m) => m.gc_fraction, + format: percent, + better: "target", + }, + { + label: "GC windows out of range", + help: "Windows whose local GC falls outside the allowed range.", + get: (m) => m.gc_windows_below_range + m.gc_windows_above_range, + better: "lower", + }, + { + label: "Codon adaptation index", + help: "How closely the codons follow the host's preferences (1 is ideal).", + get: (m) => m.cai, + format: (v) => fmt(v, 3), + better: "higher", + }, + { + label: `Repeated ${after.repeat_k}-mers`, + help: "Extra copies of any stretch that appears more than once. Repeats misassemble during synthesis.", + get: (m) => m.redundant_kmers, + better: "lower", + }, + { + label: "Hairpin pairs", + help: "Pairs of inverted repeats that can fold into stem-loops.", + get: (m) => m.hairpin_pairs, + better: "lower", + }, + { + label: "Longest single-base run", + help: "The longest stretch of one repeated base.", + get: (m) => m.longest_homopolymer, + better: "lower", + }, + { + label: "Forbidden sites", + help: "Matches to the enzymes and patterns you asked to keep out.", + get: (m) => m.forbidden_motif_hits, + better: "lower", + }, + { + label: "Splice donor sites", + help: "Windows that look like a 5′ splice site.", + get: (m) => m.splice_donor_sites ?? 0, + better: "lower", + show: () => hasSplice, + }, + { + label: "Splice acceptor sites", + help: "Windows that look like a 3′ splice site.", + get: (m) => m.splice_acceptor_sites ?? 0, + better: "lower", + show: () => hasSplice, + }, + ]; +} + +/** Everything the optimizer was asked to remove, as one headline number. */ +function issues(m: Measures): number { + return ( + m.gc_windows_below_range + + m.gc_windows_above_range + + m.redundant_kmers + + m.hairpin_pairs + + m.homopolymer_excess_bases + + m.forbidden_motif_hits + + (m.splice_donor_sites ?? 0) + + (m.splice_acceptor_sites ?? 0) + ); +} + +function comparison(result: Result): HTMLElement { + const target = result.gc.target; + const body = rows(result.final) + .filter((row) => !row.show || row.show(result.final)) + .map((row) => { + const before = row.get(result.initial); + const after = row.get(result.final); + const format = row.format ?? int; + let verdict: "good" | "bad" | "same"; + if (row.better === "target") { + const distance = Math.abs(after - target) - Math.abs(before - target); + verdict = Math.abs(distance) < 5e-4 ? "same" : distance < 0 ? "good" : "bad"; + } else { + const delta = after - before; + verdict = Math.abs(delta) < 1e-9 ? "same" : (delta < 0) === (row.better === "lower") ? "good" : "bad"; + } + const words = { good: "better", bad: "worse", same: "no change" }[verdict]; + const mark = { good: "✓", bad: "!", same: "–" }[verdict]; + return h( + "tr", + null, + h("th", { attrs: { scope: "row", title: row.help } }, row.label), + h("td", { class: "num", text: format(before) }), + h("td", { class: "num strong", text: format(after) }), + h("td", { class: `verdict ${verdict}` }, h("span", { attrs: { "aria-hidden": "true" } }, `${mark} `), words), + ); + }); + return h( + "table", + { class: "compare" }, + h( + "thead", + null, + h( + "tr", + null, + h("th", { attrs: { scope: "col" } }, "Property"), + h("th", { class: "num", attrs: { scope: "col" } }, "Before"), + h("th", { class: "num", attrs: { scope: "col" } }, "After"), + h("th", { attrs: { scope: "col" } }, "Change"), + ), + ), + h("tbody", null, ...body), + ); +} + +const TERMS: Record = { + gc_global: ["Overall GC", "Distance of the whole-sequence GC from the target."], + gc_window: ["Local GC", "How far windows stray outside the allowed range."], + repeat: ["Repeats", "Redundant copies of repeated stretches."], + hairpin: ["Hairpins", "Inverted repeats that can fold back on themselves."], + homopolymer: ["Single-base runs", "Bases beyond the longest run you allow."], + motif: ["Forbidden sites", "Matches to enzymes and patterns you keep out."], + splice: ["Splice sites", "How far splice-site scores exceed their cutoffs."], + usage: ["Codon usage", "Cost of using codons the host dislikes."], +}; + +function breakdown(result: Result): HTMLElement { + const before = result.score.initial_terms; + const after = result.score.final_terms; + const names = Object.keys(TERMS).filter((key) => (before[key] ?? 0) > 1e-9 || (after[key] ?? 0) > 1e-9); + if (!names.length) { + return h("p", { class: "hint", text: "No penalties at all: every constraint is met and codon usage is ideal." }); + } + const scale = Math.max(...names.flatMap((key) => [before[key] ?? 0, after[key] ?? 0]), 1e-9); + const bar = (value: number, className: string): HTMLElement => { + const fill = h("span", { class: `fill ${className}` }); + fill.style.width = `${Math.max(value > 0 ? 1.5 : 0, (value / scale) * 100)}%`; + return h("span", { class: "track" }, fill); + }; + return h( + "div", + { class: "terms" }, + ...names.map((key) => + h( + "div", + { class: "term", title: TERMS[key][1] }, + h("span", { class: "term-name", text: TERMS[key][0] }), + h( + "span", + { class: "term-bars" }, + bar(before[key] ?? 0, "before"), + bar(after[key] ?? 0, "after"), + ), + h("span", { class: "term-values mono", text: `${fmt(before[key] ?? 0, 2)} → ${fmt(after[key] ?? 0, 2)}` }), + ), + ), + ); +} + +function card(title: string, subtitle: string | null, ...content: Child[]): HTMLElement { + return h( + "section", + { class: "card" }, + h("h3", { text: title }), + subtitle ? h("p", { class: "hint", text: subtitle }) : null, + ...content, + ); +} + +function stat(label: string, before: string, after: string, note: string): HTMLElement { + return h( + "div", + { class: "stat" }, + h("span", { class: "stat-label", text: label }), + h("span", { class: "stat-value" }, h("span", { class: "was", text: before }), " → ", h("strong", { text: after })), + h("span", { class: "stat-note", text: note }), + ); +} + +export function showResult(output: HTMLElement, result: Result, _meta: Meta): void { + const issuesBefore = issues(result.initial); + const issuesAfter = issues(result.final); + const reduction = + result.score.initial > 0 + ? `${Math.round((1 - result.score.final / result.score.initial) * 100)}% lower score` + : "score unchanged"; + + const name = safeName(result.name); + const banner = result.cancelled + ? h("div", { class: "callout warn", role: "status" }, "Stopped early. This is the best sequence found so far.") + : result.search.hit_time_limit + ? h("div", { class: "callout warn", role: "status" }, "The time limit ended the search before it settled. Raise the limit or use a thorough search for more.") + : null; + + const toolbar = h( + "div", + { class: "toolbar" }, + action("Copy DNA", (b) => void copyText(result.sequence).then((ok) => flash(b, ok ? "Copied" : "Copy failed")), true), + action("Copy FASTA", (b) => void copyText(result.fasta).then((ok) => flash(b, ok ? "Copied" : "Copy failed"))), + action("Download FASTA", () => download(`${name}.codonopt.fna`, result.fasta, "text/plain")), + action("Download report", () => + download(`${name}.codonopt.json`, JSON.stringify(result, null, 2) + "\n", "application/json"), + ), + ); + + replace( + output, + h( + "div", + { class: "result-head" }, + h("div", null, h("h2", { text: "Optimized sequence" }), h("p", { class: "hint", text: `${result.name} · ${result.protein.length.toLocaleString()} residues · ${result.codon_usage_table} codon usage` })), + toolbar, + ), + banner, + h( + "div", + { class: "stats" }, + stat("Issues found", int(issuesBefore), int(issuesAfter), issuesAfter === 0 ? "none left" : "still to resolve"), + stat("Score", fmt(result.score.initial, 2), fmt(result.score.final, 2), reduction), + stat("GC content", percent(result.initial.gc_fraction), percent(result.final.gc_fraction), `target ${percent(result.gc.target)}`), + stat("Codon adaptation", fmt(result.initial.cai, 2), fmt(result.final.cai, 2), "1.0 is ideal"), + ), + card("DNA sequence", "The protein is identical to your input; only synonymous codons changed.", sequenceView(result)), + card("Before and after", null, comparison(result)), + card("GC content along the sequence", `Each point is a ${result.gc.window} base window.`, gcChart(result.gc, result.sequence.length)), + card("Where the score comes from", "Lower is better. Each term is weighted as in your settings.", breakdown(result)), + h( + "p", + { class: "run-info hint" }, + `${result.search.restarts_run} ${result.search.restarts_run === 1 ? "restart" : "restarts"} · ${int(result.search.greedy_sweeps)} sweeps · ${int(result.search.moves_evaluated)} codon swaps tried · ${fmt(result.search.seconds, 1)} s`, + ), + ); + output.focus({ preventScroll: true }); +} diff --git a/web/src/sequence.ts b/web/src/sequence.ts new file mode 100644 index 0000000..0d6b51b --- /dev/null +++ b/web/src/sequence.ts @@ -0,0 +1,99 @@ +import { h, int } from "./dom"; +import type { Result } from "./types"; + +const CODONS_PER_ROW = 20; + +/** + * The optimized sequence, codon by codon, with the protein above it. + * + * Codons that differ from the starting sequence can be highlighted, and any + * codon that still overlaps a forbidden motif is marked so it cannot be missed. + */ +export function sequenceView(result: Result): HTMLElement { + const final = result.sequence; + const initial = result.initial_sequence; + const codons = final.length / 3; + + const flagged = new Set(); + for (const hit of result.motif_hits) { + for (const position of hit.positions) { + for (let i = Math.floor(position / 3); i <= Math.floor((position + hit.length - 1) / 3); i++) { + flagged.add(i); + } + } + } + + let changed = 0; + const rows: HTMLElement[] = []; + const labelWidth = String(final.length).length; + for (let first = 0; first < codons; first += CODONS_PER_ROW) { + const last = Math.min(first + CODONS_PER_ROW, codons); + const aminoAcids = h("div", { class: "line aa", attrs: { "aria-hidden": "true" } }); + const bases = h("div", { class: "line dna" }); + for (let i = first; i < last; i++) { + const codon = final.slice(i * 3, i * 3 + 3); + const before = initial.slice(i * 3, i * 3 + 3); + const differs = codon !== before; + if (differs) changed++; + const classes = ["cell", differs ? "chg" : "", flagged.has(i) ? "hit" : ""].filter(Boolean).join(" "); + aminoAcids.append(h("span", { class: "cell" }, result.protein[i] ?? "")); + bases.append( + h("span", differs ? { class: classes, title: `${before} → ${codon}` } : { class: classes }, codon), + ); + } + rows.push( + h( + "div", + { class: "block" }, + h("span", { class: "pos", text: String(first * 3 + 1).padStart(labelWidth, " ") }), + h("div", { class: "lines" }, aminoAcids, bases), + ), + ); + } + + const view = h("div", { class: "seq show-changes show-aa", attrs: { tabindex: "0", "aria-label": "Optimized DNA sequence" } }, ...rows); + + const toggle = (label: string, className: string, checked: boolean): HTMLElement => + h( + "label", + { class: "check" }, + h("input", { + type: "checkbox", + checked, + on: { change: (event) => view.classList.toggle(className, (event.target as HTMLInputElement).checked) }, + }), + ` ${label}`, + ); + if (!result.motif_hits.length && !changed) view.classList.remove("show-changes"); + + const summary = + changed === 0 + ? "No codon needed to change." + : `${int(changed)} of ${int(codons)} codons changed (${Math.round((changed / codons) * 100)}%).`; + const warn = result.motif_hits.length + ? h( + "p", + { class: "note warn", role: "status" }, + `Still contains ${result.motif_hits.map((hit) => `${hit.name} (${hit.positions.length})`).join(", ")}; ` + + "the marked codons overlap a site you asked to keep out.", + ) + : null; + + return h( + "div", + { class: "seq-wrap" }, + h( + "div", + { class: "seq-tools" }, + h("p", { class: "hint", text: summary }), + h( + "div", + { class: "toggles" }, + toggle("Highlight changes", "show-changes", view.classList.contains("show-changes")), + toggle("Show protein", "show-aa", true), + ), + ), + warn, + view, + ); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..5dc66b0 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,318 @@ +/* codonopt interface. System fonts and colours only: the page loads nothing + from outside, and the content security policy would refuse it if it tried. */ + +:root { + --bg: #f4f6f4; + --surface: #ffffff; + --surface-2: #eef1ee; + --text: #17201e; + --muted: #56635f; + --border: #d9dfdb; + --accent: #0f766e; + --accent-strong: #0b5a54; + --accent-ink: #ffffff; + --accent-soft: #d6eeea; + --good: #146c37; + --bad: #b42318; + --warn: #8a4b08; + --warn-soft: #fdecc8; + --error-soft: #fde4e1; + --before: #7b8794; + --focus: #1d4ed8; + --shadow: 0 1px 2px rgb(16 24 22 / 8%), 0 4px 16px rgb(16 24 22 / 5%); + --radius: 12px; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0e1413; + --surface: #161e1c; + --surface-2: #1d2725; + --text: #e5ece9; + --muted: #98a7a1; + --border: #2a3835; + --accent: #3fb6aa; + --accent-strong: #7fd6cc; + --accent-ink: #06201d; + --accent-soft: #17403b; + --good: #5fd38b; + --bad: #ff8a80; + --warn: #f2b45a; + --warn-soft: #3a2a10; + --error-soft: #3b1a17; + --before: #8794a3; + --focus: #8ab4ff; + --shadow: 0 1px 2px rgb(0 0 0 / 40%); + } +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } + +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 var(--sans); +} + +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +h1, h2, h3, p { margin: 0; } +h1 { font-size: 1.15rem; line-height: 1.2; letter-spacing: -0.01em; } +h2 { font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); font-weight: 650; } +h3 { font-size: 1rem; } + +.sr-only { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} +.mono { font-family: var(--mono); } +.hint { color: var(--muted); font-size: 0.8125rem; } + +/* ---------- header ---------- */ + +.top { + max-width: 1280px; margin: 0 auto; padding: 16px 16px 4px; + display: flex; align-items: center; justify-content: space-between; gap: 12px; +} +.brand { display: flex; align-items: center; gap: 12px; } +.brand p { color: var(--muted); font-size: 0.8125rem; } +.logo { width: 36px; height: 36px; flex: none; } +.logo-bg { fill: var(--accent); } +.logo-strand { fill: none; stroke: var(--accent-ink); stroke-width: 2.4; stroke-linecap: round; } +.version { color: var(--muted); font: 0.75rem var(--mono); } + +/* ---------- layout ---------- */ + +.app { + max-width: 1280px; margin: 0 auto; padding: 12px 16px 48px; + display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr); +} +@media (min-width: 980px) { + .app { grid-template-columns: 430px minmax(0, 1fr); align-items: start; } + .panel { position: sticky; top: 16px; max-height: calc(100vh - 32px); } + .panel-scroll { overflow-y: auto; } +} + +.panel { + display: flex; flex-direction: column; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow); +} +.panel[inert] { opacity: 0.55; } +.panel-scroll { padding: 4px 18px 8px; min-height: 0; } +.actions { + position: sticky; bottom: 0; display: flex; gap: 8px; padding: 12px 18px; + background: var(--surface); border-top: 1px solid var(--border); + border-radius: 0 0 var(--radius) var(--radius); +} + +.group { padding: 16px 0; border-bottom: 1px solid var(--border); display: grid; gap: 10px; } +.group:last-child { border-bottom: 0; } +.group-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +.tools { display: flex; gap: 12px; } +.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } + +/* ---------- controls ---------- */ + +textarea, input[type="text"], input[type="number"], select { + width: 100%; font: inherit; color: var(--text); background: var(--surface); + border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; +} +textarea { font-family: var(--mono); font-size: 0.8125rem; line-height: 1.45; resize: vertical; min-height: 7rem; } +input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.8; } +textarea:hover, input:hover, select:hover { border-color: color-mix(in srgb, var(--border), var(--text) 25%); } + +.field { display: grid; gap: 4px; min-width: 0; } +.field > span, .small-label { font-size: 0.8125rem; color: var(--muted); } +.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.grid3 .wide { grid-column: span 2; } + +.link { + background: none; border: 0; padding: 0; font: inherit; font-size: 0.8125rem; + color: var(--accent-strong); cursor: pointer; text-decoration: underline; + text-underline-offset: 3px; +} +.link:hover { text-decoration-thickness: 2px; } + +.check { display: inline-flex; align-items: center; gap: 6px; font-size: 0.875rem; cursor: pointer; } +.check input { width: 16px; height: 16px; accent-color: var(--accent); } + +.seg { + position: relative; display: inline-flex; margin: 0; padding: 2px; min-width: 0; + border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); +} +.seg input { position: absolute; opacity: 0; pointer-events: none; } +.seg label { + padding: 5px 12px; border-radius: 8px; cursor: pointer; font-size: 0.875rem; color: var(--muted); +} +.seg input:checked + label { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: 0 1px 2px rgb(0 0 0 / 15%); } +.seg input:focus-visible + label { outline: 2px solid var(--focus); outline-offset: 1px; } + +.detected { font-size: 0.8125rem; color: var(--muted); } +.detected.ok { color: var(--accent-strong); } +.detected.error { color: var(--bad); } + +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.chip-check { + display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; font-size: 0.8125rem; + border: 1px solid var(--border); border-radius: 999px; cursor: pointer; background: var(--surface); +} +.chip-check:has(input:checked) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-strong); font-weight: 600; } +.chip-check input { accent-color: var(--accent); } + +.advanced > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; } +.advanced > summary::-webkit-details-marker { display: none; } +.advanced > summary::before { content: "▸"; color: var(--muted); transition: transform 0.15s; } +.advanced[open] > summary::before { transform: rotate(90deg); } +.advanced[open] { gap: 12px; } +.weights { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + +button.primary, button.secondary { + font: inherit; font-weight: 650; border-radius: 10px; padding: 10px 16px; cursor: pointer; + border: 1px solid transparent; flex: 1; +} +button.primary { background: var(--accent); color: var(--accent-ink); } +button.primary:hover:not(:disabled) { background: var(--accent-strong); color: var(--accent-ink); } +button.primary:disabled { opacity: 0.45; cursor: not-allowed; } +button.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border); } +button.secondary:hover { border-color: var(--muted); } + +/* ---------- output ---------- */ + +.output { min-width: 0; display: grid; gap: 16px; outline: none; } + +.empty { padding: 28px 24px; background: var(--surface); border: 1px dashed var(--border); border-radius: var(--radius); display: grid; gap: 14px; } +.empty h2 { font-size: 1.25rem; text-transform: none; letter-spacing: -0.01em; color: var(--text); font-weight: 650; } +.empty p { color: var(--muted); max-width: 60ch; } +.steps { margin: 0; padding-left: 1.2em; display: grid; gap: 6px; } +.steps strong { color: var(--accent-strong); } + +.card { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow); padding: 16px 18px; display: grid; gap: 12px; min-width: 0; +} + +.callout { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface-2); } +.callout.error { background: var(--error-soft); border-color: var(--bad); color: var(--text); display: grid; gap: 4px; } +.callout.error strong { color: var(--bad); } +.callout.warn { background: var(--warn-soft); border-color: var(--warn); color: var(--text); } + +.result-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.result-head h2 { font-size: 1.25rem; text-transform: none; letter-spacing: -0.01em; color: var(--text); font-weight: 650; } +.toolbar { display: flex; flex-wrap: wrap; gap: 8px; } +.chip { + font: inherit; font-size: 0.8125rem; padding: 6px 12px; border-radius: 999px; cursor: pointer; + background: var(--surface); color: var(--text); border: 1px solid var(--border); +} +.chip:hover { border-color: var(--muted); } +.primary-chip { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; } +.primary-chip:hover { background: var(--accent-strong); border-color: var(--accent-strong); } + +.stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); } +.stat { + display: grid; gap: 2px; padding: 12px 14px; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); +} +.stat-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 650; } +.stat-value { font-size: 1.35rem; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; } +.stat-value .was { color: var(--muted); } +.stat-value strong { color: var(--accent-strong); } +.stat-note { font-size: 0.8125rem; color: var(--muted); } + +/* ---------- progress ---------- */ + +.progress { gap: 10px; } +.bar { height: 8px; border-radius: 999px; background: var(--surface-2); overflow: hidden; } +.bar-fill { height: 100%; width: 0; background: var(--accent); border-radius: 999px; transition: width 0.3s; } +.bar.indeterminate .bar-fill { width: 35% !important; animation: slide 1.4s ease-in-out infinite; } +@keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } } +@media (prefers-reduced-motion: reduce) { + .bar.indeterminate .bar-fill { animation: none; width: 100% !important; opacity: 0.5; } +} +.progress-meta { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; font-size: 0.8125rem; } +.log { margin: 0; padding: 8px 10px 8px 28px; max-height: 9rem; overflow: auto; font-size: 0.75rem; color: var(--muted); background: var(--surface-2); border-radius: 8px; } + +/* ---------- sequence ---------- */ + +.seq-wrap { display: grid; gap: 10px; min-width: 0; } +.seq-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; } +.toggles { display: flex; gap: 14px; flex-wrap: wrap; } +.note.warn { color: var(--warn); font-size: 0.8125rem; } + +.seq { + font-family: var(--mono); font-size: 0.8125rem; line-height: 1.35; overflow-x: auto; + padding: 10px 12px; background: var(--surface-2); border-radius: 10px; max-height: 26rem; overflow-y: auto; + font-variant-ligatures: none; +} +.block { display: flex; gap: 12px; padding: 3px 0; white-space: nowrap; } +.pos { color: var(--muted); white-space: pre; user-select: none; text-align: right; flex: none; } +.line { display: block; } +.line .cell { display: inline-block; width: 4ch; } +.line.aa { color: var(--muted); font-size: 0.75rem; display: none; } +.seq.show-aa .line.aa { display: block; } +.seq.show-changes .cell.chg { background: var(--accent-soft); color: var(--accent-strong); border-radius: 3px; font-weight: 600; } +.cell.hit { color: var(--bad); text-decoration: underline wavy; text-underline-offset: 3px; font-weight: 700; } + +/* ---------- comparison table ---------- */ + +.compare { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +.compare th, .compare td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; } +.compare thead th { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); } +.compare tbody th { font-weight: 500; } +.compare tbody tr:last-child th, .compare tbody tr:last-child td { border-bottom: 0; } +.compare .num { text-align: right; font-variant-numeric: tabular-nums; } +.compare .strong { font-weight: 650; } +.verdict { white-space: nowrap; } +.verdict.good { color: var(--good); } +.verdict.bad { color: var(--bad); font-weight: 600; } +.verdict.same { color: var(--muted); } +.card:has(.compare) { overflow-x: auto; } + +/* ---------- chart ---------- */ + +.figure { margin: 0; display: grid; gap: 8px; overflow-x: auto; } +svg.chart { width: 100%; min-width: 560px; height: auto; display: block; } +.chart .band { fill: var(--accent-soft); opacity: 0.7; } +.chart .grid { stroke: var(--border); stroke-width: 1; } +.chart .grid.faint { stroke-dasharray: 2 4; opacity: 0.6; } +.chart .tick { fill: var(--muted); font: 11px var(--sans); } +.chart .target { stroke: var(--accent-strong); stroke-width: 1; stroke-dasharray: 5 4; } +.chart .line { fill: none; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; } +.chart .line.before, .chart .dot.before { stroke: var(--before); fill: none; opacity: 0.9; } +.chart .line.after { stroke: var(--accent); stroke-width: 2.2; } +.chart .dot.before { fill: var(--before); } +.chart .dot.after { fill: var(--accent); } +.chart .cursor { stroke: var(--muted); stroke-width: 1; } +.chart .overlay { fill: transparent; cursor: crosshair; } +.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0; padding: 0; list-style: none; font-size: 0.8125rem; color: var(--muted); } +.legend li { display: inline-flex; align-items: center; gap: 6px; } +.swatch { display: inline-block; width: 18px; height: 3px; border-radius: 2px; } +.swatch.before { background: var(--before); } +.swatch.after { background: var(--accent); height: 4px; } +.swatch.band { background: var(--accent-soft); height: 10px; border: 1px solid var(--accent); } +.swatch.target { height: 0; border-top: 2px dashed var(--accent-strong); background: none; } +.readout { font-size: 0.8125rem; color: var(--muted); min-height: 1.4em; font-variant-numeric: tabular-nums; } + +/* ---------- score breakdown ---------- */ + +.terms { display: grid; gap: 10px; } +.term { display: grid; grid-template-columns: 8.5rem minmax(0, 1fr) auto; align-items: center; gap: 12px; font-size: 0.875rem; } +.term-bars { display: grid; gap: 3px; } +.track { display: block; height: 7px; background: var(--surface-2); border-radius: 4px; overflow: hidden; } +.fill { display: block; height: 100%; border-radius: 4px; } +.fill.before { background: var(--before); } +.fill.after { background: var(--accent); } +.term-values { font-size: 0.75rem; color: var(--muted); text-align: right; white-space: nowrap; } +@media (max-width: 560px) { + .term { grid-template-columns: 1fr; gap: 4px; } + .term-values { text-align: left; } + .weights { grid-template-columns: 1fr; } +} + +.run-info { text-align: center; } +.noscript { padding: 16px; text-align: center; } diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..b158dd7 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,117 @@ +/** Shapes of the JSON the codonopt server sends. Mirrors codonopt/server.py. */ + +export interface Defaults { + gc_target: number; + gc_range: [number, number]; + gc_window: number; + gc_step: number; + repeat_k: number; + hairpin_stem: number; + hairpin_min_loop: number; + hairpin_max_loop: number; + max_homopolymer: number; + seconds: number; +} + +export interface EffortLevel { + max_sweeps: number; + restarts: number; + anneal_steps: number; +} + +export interface Meta { + version: string; + tables: { name: string; description: string }[]; + default_table: string; + motif_groups: Record; + restriction_sites: Record; + regulatory_motifs: Record; + splice_backends: string[]; + maxent_available: boolean; + weights: Record; + defaults: Defaults; + effort: Record; + limits: { max_residues: number; max_seconds: number }; + example: { name: string; protein: string }; +} + +export interface Measures { + length: number; + gc_fraction: number; + gc_windows_below_range: number; + gc_windows_above_range: number; + cai: number; + repeat_k: number; + redundant_kmers: number; + hairpin_pairs: number; + longest_hairpin_stem: number; + longest_homopolymer: number; + homopolymer_excess_bases: number; + forbidden_motif_hits: number; + forbidden_motifs_present: string[]; + splice_backend?: string; + splice_donor_sites?: number; + splice_acceptor_sites?: number; +} + +export interface GcProfile { + window: number; + step: number; + range: [number, number]; + target: number; + starts: number[]; + initial: number[]; + final: number[]; +} + +export interface MotifHit { + name: string; + length: number; + positions: number[]; +} + +export interface Result { + name: string; + kind: "protein" | "dna"; + protein: string; + sequence: string; + initial_sequence: string; + cancelled: boolean; + codon_usage_table: string; + score: { + initial: number; + final: number; + improvement: number; + initial_terms: Record; + final_terms: Record; + }; + search: { + restarts_run: number; + greedy_sweeps: number; + moves_evaluated: number; + moves_accepted: number; + seconds: number; + hit_time_limit: boolean; + }; + initial: Measures; + final: Measures; + gc: GcProfile; + motif_hits: MotifHit[]; + fasta: string; +} + +export type JobStatus = "running" | "done" | "cancelled" | "error"; + +export interface Job { + id: string; + status: JobStatus; + elapsed: number; + max_seconds: number; + restarts_total: number; + restarts_done: number; + messages: string[]; + error?: string; + result?: Result; +} + +export type Kind = "auto" | "protein" | "dna"; diff --git a/web/tests/e2e.spec.ts b/web/tests/e2e.spec.ts new file mode 100644 index 0000000..1cc94dc --- /dev/null +++ b/web/tests/e2e.spec.ts @@ -0,0 +1,233 @@ +import { expect, test, type Page } from "@playwright/test"; + +const SHOTS = process.env.CODONOPT_SHOTS ?? ""; +const shot = async (page: Page, name: string): Promise => { + if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }); +}; + +/** + * Open the page and wait until it is actually usable. + * + * The form carries `inert` until the defaults have arrived, which is the only + * honest readiness signal: until then the buttons exist but do nothing. + * Returns the list of browser errors seen, which every test asserts is empty. + */ +async function open(page: Page): Promise { + const errors: string[] = []; + page.on("console", (message) => { + // A deliberate 400 is logged by the browser as a console error; what we are + // watching for is thrown exceptions and content-policy refusals. + if (message.type() === "error" && !/Failed to load resource/.test(message.text())) { + errors.push(message.text()); + } + }); + page.on("pageerror", (error) => errors.push(String(error))); + await page.goto("/"); + await page.waitForFunction(() => !document.getElementById("form")?.hasAttribute("inert")); + return errors; +} + +/** Pick the quick setting so the suite stays fast. */ +async function quick(page: Page): Promise { + await page.locator('label[for="effort-quick"]').click(); +} + +test("loads with no console errors and a disabled Optimize button", async ({ page }) => { + const errors = await open(page); + await expect(page.locator("#empty")).toBeVisible(); + await expect(page.locator("#run")).toBeDisabled(); + await expect(page.locator("#table option")).not.toHaveCount(0); + await shot(page, "01-empty"); + expect(errors).toEqual([]); +}); + +test("optimises the example and shows every result section", async ({ page }) => { + const errors = await open(page); + await page.locator("#btn-example").click(); + await expect(page.locator("#run")).toBeEnabled(); + await expect(page.locator("#detected")).not.toBeEmpty(); + await quick(page); + await page.locator("#run").click(); + await expect(page.locator(".progress")).toBeVisible(); + await expect(page.locator(".result-head")).toBeVisible({ timeout: 60_000 }); + await expect(page.locator(".stats .stat").first()).toBeVisible(); + await expect(page.locator("table.compare")).toBeVisible(); + await expect(page.locator("svg.chart")).toBeVisible(); + await expect(page.locator(".seq .cell").first()).toBeVisible(); + await expect(page.locator(".callout.error")).toHaveCount(0); + + // The chart reads out a window on hover. + await page.locator("svg.chart .overlay").hover({ position: { x: 200, y: 40 } }); + await expect(page.locator(".readout")).toContainText("Bases"); + + // Download works (a blob, so it has to survive the content security policy). + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: /download fasta/i }).click(), + ]); + expect(download.suggestedFilename()).toMatch(/\.(fna|fa|fasta)$/); + await shot(page, "02-result"); + expect(errors).toEqual([]); +}); + +test("keeps the protein unchanged (the sequence is a synonymous recoding)", async ({ page }) => { + const protein = "MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVV"; + const errors = await open(page); + await page.locator("#sequence").fill(protein); + await expect(page.locator("#detected")).toContainText(/protein/i); + await quick(page); + await page.locator("#run").click(); + await expect(page.locator(".seq .cell").first()).toBeVisible({ timeout: 60_000 }); + const shown = await page.locator(".line.aa").evaluateAll((rows) => + rows.map((row) => Array.from(row.querySelectorAll(".cell")).map((cell) => cell.textContent).join("")).join(""), + ); + // Compared as text, so a failure prints the protein that was actually drawn. + expect(shown).toBe(`${protein}*`); + expect(errors).toEqual([]); +}); + +test("explains problems instead of failing silently", async ({ page }) => { + const errors = await open(page); + await page.locator("#sequence").fill("MKT*AY1"); + await expect(page.locator("#detected")).toBeVisible(); + // Whatever the badge says, running must either be blocked or give a readable error. + if (await page.locator("#run").isEnabled()) { + await page.locator("#run").click(); + await expect(page.locator(".callout.error")).toBeVisible({ timeout: 20_000 }); + } else { + await expect(page.locator("#detected")).toHaveText(/\S/); + } + await shot(page, "03-error"); + expect(errors).toEqual([]); +}); + +test("can cancel a long run and hands the controls back", async ({ page }) => { + const errors = await open(page); + await page.locator("#btn-example").click(); + await page.locator("#seconds").fill("60"); + await page.locator('label[for="effort-thorough"]').click(); + await page.locator("#run").click(); + await expect(page.locator("#cancel")).toBeVisible(); + await page.waitForTimeout(1500); + await page.locator("#cancel").click(); + // What matters is that Stop ends the run and restores the form, whatever the + // search had managed by then. + await expect(page.locator(".result-head")).toBeVisible({ timeout: 30_000 }); + await expect(page.locator("#run")).toBeEnabled(); + await expect(page.locator("#cancel")).toBeHidden(); + await expect(page.locator("#run")).toHaveText(/optimize/i); + expect(errors).toEqual([]); +}); + +test("two submits in the same moment start only one run", async ({ page }) => { + const errors = await open(page); + // Counted at the wire, because the page only ever shows one card either way: + // the damage of a second job is invisible until its result overwrites yours. + let started = 0; + page.on("request", (request) => { + if (request.method() === "POST" && new URL(request.url()).pathname === "/api/jobs") started++; + }); + await page.locator("#btn-example").click(); + await quick(page); + // A double click, or a held Enter key, reaches the handler again before the + // server has answered the first time. + await page.evaluate(() => { + const form = document.getElementById("form") as HTMLFormElement; + form.requestSubmit(); + form.requestSubmit(); + form.requestSubmit(); + }); + await expect(page.locator(".result-head")).toBeVisible({ timeout: 60_000 }); + expect(started).toBe(1); + await expect(page.locator(".result-head")).toHaveCount(1); + await expect(page.locator("#run")).toBeEnabled(); + await expect(page.locator("#cancel")).toBeHidden(); + // A second run must still work, and replace the first result. + await page.locator("#run").click(); + await expect(page.locator(".result-head")).toBeVisible({ timeout: 60_000 }); + await expect(page.locator(".result-head")).toHaveCount(1); + expect(started).toBe(2); + expect(errors).toEqual([]); +}); + +test("refuses an empty custom codon table instead of using another host", async ({ page }) => { + const errors = await open(page); + await page.locator("#btn-example").click(); + await page.locator("#table").selectOption("custom"); + await expect(page.locator("#custom-table")).toBeVisible(); + await page.locator("#run").click(); + await expect(page.locator(".callout.error")).toContainText(/custom table/i); + await expect(page.locator(".progress")).toHaveCount(0); + expect(errors).toEqual([]); +}); + +test("offers every built-in codon usage table, plus a custom one", async ({ page }) => { + const errors = await open(page); + const names = await page.locator("#table option").evaluateAll((options) => + options.map((option) => (option as HTMLOptionElement).value), + ); + const expected = await page.evaluate(async () => { + const token = document.querySelector('meta[name="codonopt-token"]')?.content ?? ""; + const meta = await fetch("/api/meta", { headers: { "X-Codonopt-Token": token } }).then((r) => r.json()); + return meta.tables.map((table: { name: string }) => table.name); + }); + expect(expected.length).toBeGreaterThan(0); + expect(names).toEqual([...expected, "custom"]); + expect(errors).toEqual([]); +}); + +test("warns before sending a sequence that is too long", async ({ page }) => { + const errors = await open(page); + const limit = await page.evaluate(async () => { + const token = document.querySelector('meta[name="codonopt-token"]')?.content ?? ""; + const meta = await fetch("/api/meta", { headers: { "X-Codonopt-Token": token } }).then((r) => r.json()); + return meta.limits.max_residues as number; + }); + // DNA used to slip past the check entirely and show a green badge. The badge + // only warns: the server stays the authority on what it will accept. + await page.locator("#sequence").fill("ATG".repeat(limit + 10)); + await expect(page.locator("#detected")).toHaveClass(/error/); + await expect(page.locator("#detected")).toContainText(/codons/); + await expect(page.locator("#detected")).toContainText(String(limit.toLocaleString())); + + // And a protein one residue under the limit is fine until "Add stop codon" + // pushes it over, which the badge used to miss. + await page.locator("#sequence").fill("M".repeat(limit)); + await page.locator("#add-stop").uncheck(); + await expect(page.locator("#detected")).toHaveClass(/ok/); + await page.locator("#add-stop").check(); + await expect(page.locator("#detected")).toHaveClass(/error/); + expect(errors).toEqual([]); +}); + +for (const scheme of ["light", "dark"] as const) { + test(`renders in ${scheme} mode`, async ({ browser }) => { + const context = await browser.newContext({ colorScheme: scheme, viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + const errors = await open(page); + await page.locator("#btn-example").click(); + await quick(page); + await page.locator("#run").click(); + await expect(page.locator("svg.chart")).toBeVisible({ timeout: 60_000 }); + await shot(page, `04-${scheme}`); + expect(errors).toEqual([]); + await context.close(); + }); +} + +test("works at phone width without horizontal page scroll", async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 390, height: 800 } }); + const page = await context.newPage(); + const errors = await open(page); + await page.locator("#btn-example").click(); + await quick(page); + await page.locator("#run").click(); + await expect(page.locator("svg.chart")).toBeVisible({ timeout: 60_000 }); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); + await shot(page, "05-phone"); + expect(errors).toEqual([]); + await context.close(); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..77fe305 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +}