Edit a sequence

Download the notebook

Click an A/C/G/T letter to choose the nucleotide at that position. Chosen bases use their nucleotide colors; the other letters fade to gray. The reference and edited sequence use the same colors. Changes from the reference are outlined in the grid.

Loading chart…

Download FASTA to keep both sequences. Reset to reference undoes all edits; reloading also resets the demo. Positions are 1-based, not genomic coordinates.

Code

A point-selection hook receives the clicked cell. It updates the named datasets for the edited sequence and grid, leaving the reference unchanged. The web demo uses JavaScript; run these Python hooks in the notebook.

Chart specification (Python)
import genome_spy as gs

REFERENCE = "ACGTGCAATGCTAGCTACGATCGA"
BASES = ["A", "C", "G", "T"]
COLORS = ["#73b76a", "#659dd2", "#e7b354", "#da7777"]
reference = [
    {"position": i + 1, "base": base, "changed": False}
    for i, base in enumerate(REFERENCE)
]


def one_hot(sequence):
    return [
        {
            "position": i + 1,
            "base": base,
            "value": int(base == chosen),
            "changed": chosen != REFERENCE[i],
            "kind": "cell",
        }
        for i, chosen in enumerate(sequence)
        for base in BASES
    ]


x = (
    gs.X("position:O")
    .scale(domain=list(range(1, len(REFERENCE) + 1)))
    .axis(title="Position (1-based)", labelAngle=0)
)
base_color = gs.Color("base:N").scale(domain=BASES, range=COLORS).legend(None)
reference_track = (
    gs.Chart(data={"name": "reference"})
    .mark_text(size=18)
    .encode(x=x.axis(None), text="base:N", color=base_color)
    .properties(height=26, title="Reference")
)
edited_track = (
    gs.Chart(data={"name": "edited"})
    .mark_text(size=18)
    .encode(
        x=x.axis(None),
        text="base:N",
        color=base_color,
    )
    .properties(height=26, title="Edited sequence")
)
cells = (
    gs.Chart(data={"name": "matrix"})
    .mark_rect(strokeWidth=2)
    .encode(
        x=x,
        y=gs.Y("base:N").scale(domain=BASES).axis(title=None),
        color=gs.Color(gs.expr("datum.value ? datum.base : 'empty'"), type="nominal")
        .scale(
            domain=[*BASES, "empty"],
            range=["#e5f1e3", "#e2edf7", "#faf0da", "#f7e5e5", "#f4f5f7"],
        )
        .legend(None),
        stroke=gs.Stroke(
            gs.expr("datum.changed && datum.value === 1"),
            type="nominal",
        )
        .scale(domain=[False, True], range=["white", "#58616b"])
        .legend(None),
        tooltip=["position:O", "base:N"],
    )
)
letters = cells.mark_text(size=18, tooltip=None).encode(
    text="base:N",
    color=gs.Color(gs.expr("datum.value ? datum.base : 'empty'"), type="nominal")
    .scale(domain=[*BASES, "empty"], range=[*COLORS, "#c5cad0"])
    .legend(None),
    stroke=gs.value("transparent"),
)
cells = cells.properties(name="base-cells").add_params(
    gs.selection_point("base_pick", toggle=False)
)
grid = (
    (cells + letters)
    .resolve_scale(color="independent")
    .properties(height=128, title="Click a letter to choose the base")
)
chart = (
    (reference_track & edited_track & grid)
    .properties(
        width=760,
        spacing=12,
        datasets={
            "reference": reference,
            "edited": reference,
            "matrix": one_hot(REFERENCE),
        },
    )
    .resolve_scale(y="independent", color="independent")
)
Python hooks
import asyncio
import html
import ipywidgets as widgets
from IPython.display import display

if old_task := globals().get("connection_task"):
    old_task.cancel()
if old_edit := globals().get("edit_task"):
    old_edit.cancel()
if old_widget := globals().get("widget"):
    old_widget.close()

widget = chart.widget(inline=True, controls=False)
status = widgets.HTML("Connecting…")
edited_sequence = list(REFERENCE)
edit_task = None


async def apply_base(row):
    try:
        edited_sequence[row["position"] - 1] = row["base"]
        await selection.clear()
        await api.datasets.set("matrix", one_hot(edited_sequence))
        await api.datasets.set(
            "edited",
            [
                {"position": i + 1, "base": base, "changed": base != REFERENCE[i]}
                for i, base in enumerate(edited_sequence)
            ],
        )
        status.value = html.escape("Edited: " + "".join(edited_sequence))
    except Exception as error:
        status.value = html.escape(f"Update failed: {error}")


def choose_base(snapshot):
    global edit_task
    if not snapshot["data"]:
        return
    row = snapshot["data"][-1]
    if row.get("kind") != "cell":
        return
    # Serialize updates so a rapid click cannot overtake a previous edit.
    if edit_task is not None and not edit_task.done():
        status.value = "Finishing the previous edit; click again."
        return
    edit_task = asyncio.create_task(apply_base(row))


async def connect():
    global api, selection, stop
    try:
        api = await widget.get_embed_api()
        view = await api.views.get({"scope": [], "view": "base-cells"})
        selection = await view.params.get_selection("base_pick")
        stop = await selection.subscribe(choose_base)
        status.value = "Ready. Click a cell to change that position."
    except Exception as error:
        status.value = html.escape(f"Connection failed: {error}")


display(widget, status)
connection_task = asyncio.create_task(connect())

Data use and provenance

An invented 24-base sequence for demonstrating editing, not a biological reference or a prediction of mutation effects. Inspired by GenomeSpy’s sequence editor.