Skip to content

Lambda Phage Gene Map

This example recreates a gbdraw lambda phage plot using GenomeSpy's declarative grammar. Forward and reverse coding sequences appear on opposite sides of the genome backbone, with underlined product labels connected to their gene midpoints.

{
  "description": [
    "Lambda Phage Gene Map",
    "Recreates a gbdraw gene map with strand-directed arrows and product labels in a bottom-anchored scrollable track.",
    "Data source: NCBI/NLM RefSeq NC_001416.1; coordinate-normalized CDS extract."
  ],

  "title": "Lambda phage (NC_001416.1, 48,502 bp)",

  "data": {
    "url": "https://data.genomespy.app/datasets/refseq-lambda-phage-cds/v1/lambda-cds.json"
  },

  "params": [
    { "name": "highlight", "value": null, "persist": false },
    { "name": "labelFontSize", "value": 12 },
    { "name": "laneStep", "value": 20 },
    { "name": "underlineClearance", "value": 4 },
    {
      "name": "pixelsPerBase",
      "expr": "width * abs(scale('x', 1) - scale('x', 0))"
    }
  ],

  "transform": [
    {
      "type": "identifier",
      "description": "Share gene identity across labels, connectors, and arrows for coordinated hover highlighting."
    },
    {
      "type": "formula",
      "description": "Anchor labels and leaders at the gene midpoint.",
      "expr": "(datum.start + datum.end) / 2",
      "as": "mid"
    },
    { "type": "formula", "expr": "datum.start + 1", "as": "start1" }
  ],

  "background": "white",

  "scales": {
    "x": {
      "name": "genome",
      "domain": [5000, 32000],
      "zoom": { "extent": [0, 48501] }
    }
  },

  "encoding": {
    "x": {
      "field": "start",
      "type": "index",
      "band": 0,
      "axis": {
        "title": "Genome position (bp)",
        "tickCount": 10,
        "extraValues": [0],
        "grid": false,
        "domainColor": "#222222",
        "domainWidth": 1.5,
        "tickColor": "#222222",
        "tickSize": 6,
        "labelFontSize": 13,
        "labelPadding": 7,
        "titleFontSize": 13
      }
    }
  },

  "spacing": 0,

  "resolve": {
    "scale": { "x": "shared", "y": "independent" },
    "axis": { "x": "shared" }
  },

  "vconcat": [
    {
      "name": "labelPanel",
      "description": "Fill the container above the arrow track with a bottom-anchored viewport. Content grows upward as pileup needs more rows.",
      "transform": [
        {
          "type": "measureText",
          "description": "Measure label widths using the same font and size as the text marks.",
          "field": "product",
          "fontSize": { "expr": "labelFontSize" },
          "font": "Lato",
          "as": "labelWidth"
        },
        {
          "type": "collect",
          "description": "Cache measurements so zooming and resizing replay only the layout steps below."
        },
        {
          "type": "filter",
          "description": "Label only genes whose midpoint is visible.",
          "expr": "datum.mid >= invert('x', 0) && datum.mid <= invert('x', 1)"
        },
        {
          "type": "formula",
          "expr": "clamp(width * scale('x', datum.mid) + (datum.mid - floor(datum.mid)) * pixelsPerBase - datum.labelWidth / 2, 0, width - datum.labelWidth)",
          "description": "Clamp actual label bounds around the exact gene midpoint; keep spacing out of clamping and flushed starts exactly zero.",
          "as": "labelStart"
        },
        {
          "type": "formula",
          "expr": "datum.labelStart + datum.labelWidth",
          "description": "Use the actual clamped text bounds; pileup adds spacing separately.",
          "as": "labelEnd"
        },
        {
          "type": "formula",
          "expr": "datum.labelStart + datum.labelWidth / 2 - width * scale('x', datum.mid)",
          "description": "Position the label center relative to its genomic anchor using only pixel offsets.",
          "as": "labelOffset"
        },
        {
          "type": "collect",
          "sort": {
            "field": ["labelStart", "mid", "locusTag"],
            "order": ["ascending", "ascending", "ascending"]
          },
          "description": "Sort occupied starts for pileup; break ties by genomic position and ID to keep flushed labels in stable order."
        },
        {
          "type": "pileup",
          "description": "Assign collision-free label lanes with an eight-pixel gap between actual label bounds.",
          "start": "labelStart",
          "end": "labelEnd",
          "spacing": 8,
          "as": "lane"
        }
      ],
      "encoding": {
        "x": { "field": "mid", "type": "index", "band": 0 },
        "y": { "field": "lane", "type": "index", "band": 0, "axis": null }
      },
      "layer": [
        {
          "name": "labelLeaders",
          "mark": {
            "type": "rule",
            "size": 0.8,
            "clip": "x",
            "tooltip": null
          },
          "encoding": {
            "color": {
              "condition": {
                "param": "highlight",
                "empty": false,
                "value": "red"
              },
              "value": "#929292"
            },
            "x": { "field": "mid", "type": "index", "band": 0 },
            "xOffset": {
              "expr": "(datum.mid - floor(datum.mid)) * pixelsPerBase",
              "type": "quantitative",
              "scale": null
            },
            "y2": { "value": 0 }
          },
          "description": "Leaders stop at the lower edge of the label track; the arrow track continues them."
        },
        {
          "name": "labelBackgrounds",
          "mark": {
            "type": "rect",
            "fill": "white",
            "tooltip": null,
            "yOffset": { "expr": "underlineClearance" },
            "y2Offset": { "expr": "-4 - labelFontSize" }
          },
          "encoding": {
            "x": { "field": "mid", "type": "index", "band": 0 },
            "x2": { "field": "mid", "band": 0 },
            "xOffset": {
              "expr": "datum.labelOffset - datum.labelWidth / 2",
              "type": "quantitative",
              "scale": null
            },
            "x2Offset": { "expr": "datum.labelOffset + datum.labelWidth / 2" },
            "y2": { "field": "lane", "band": 0 }
          }
        },
        {
          "name": "labelConnectors",
          "description": "Restore only the owning leader across the white clearance below its underline. The extra pixel overlaps the long leader beyond the mask.",
          "mark": {
            "type": "rule",
            "size": 0.8,
            "clip": "x",
            "tooltip": null,
            "y2Offset": { "expr": "underlineClearance + 1" }
          },
          "encoding": {
            "color": {
              "condition": {
                "param": "highlight",
                "empty": false,
                "value": "red"
              },
              "value": "#929292"
            },
            "x": { "field": "mid", "type": "index", "band": 0 },
            "xOffset": {
              "expr": "(datum.mid - floor(datum.mid)) * pixelsPerBase",
              "type": "quantitative",
              "scale": null
            },
            "y2": { "field": "lane", "band": 0 }
          }
        },
        {
          "name": "labelUnderlines",
          "mark": {
            "type": "rule",
            "size": 0.8,
            "tooltip": null
          },
          "encoding": {
            "color": {
              "condition": {
                "param": "highlight",
                "empty": false,
                "value": "red"
              },
              "value": "#929292"
            },
            "x": { "field": "mid", "type": "index", "band": 0 },
            "x2": { "field": "mid", "band": 0 },
            "xOffset": {
              "expr": "datum.labelOffset - datum.labelWidth / 2",
              "type": "quantitative",
              "scale": null
            },
            "x2Offset": { "expr": "datum.labelOffset + datum.labelWidth / 2" }
          }
        },
        {
          "name": "labelText",
          "params": [
            {
              "name": "highlight",
              "select": {
                "type": "point",
                "on": "pointerover",
                "clear": "mouseout"
              },
              "push": "outer",
              "persist": false
            }
          ],
          "mark": {
            "type": "text",
            "font": "Lato",
            "size": { "expr": "labelFontSize" },
            "align": "center",
            "baseline": "bottom",
            "dy": -4,
            "clip": true,
            "tooltip": null
          },
          "encoding": {
            "color": {
              "condition": {
                "param": "highlight",
                "empty": false,
                "value": "red"
              },
              "value": "#171717"
            },
            "x": { "field": "mid", "type": "index", "band": 0 },
            "xOffset": {
              "field": "labelOffset",
              "type": "quantitative",
              "scale": null
            },
            "text": { "field": "product" }
          }
        }
      ],
      "height": { "step": { "expr": "laneStep" } },
      "scales": {
        "y": {
          "name": "labelLanes",
          "type": "index",
          "zoom": false,
          "domainMin": -1,
          "domainTransition": false,
          "padding": 0
        }
      },
      "viewportHeight": { "grow": 1, "anchor": "end" }
    },

    {
      "name": "arrowPanel",
      "description": "Two fixed strand bands replace pixel expressions for arrow positions.",
      "height": { "step": 30 },
      "encoding": {
        "y": {
          "field": "strand",
          "type": "nominal",
          "scale": {
            "name": "strandRows",
            "type": "band",
            "domain": ["+", "-"],
            "reverse": true,
            "padding": 0
          },
          "axis": null
        }
      },
      "layer": [
        {
          "name": "backbone",
          "data": { "values": [{}] },
          "mark": {
            "type": "rule",
            "color": "#d0d0d0",
            "size": 3,
            "tooltip": null
          },
          "encoding": {
            "x": { "datum": 0, "type": "index", "band": 0 },
            "x2": { "datum": 48502, "band": 0 },
            "y": { "value": 0.5 }
          }
        },
        {
          "name": "arrowLeaders",
          "description": "Continue midpoint leaders from the top edge to the strand row; arrows cover the endpoints.",
          "mark": {
            "type": "rule",
            "size": 0.8,
            "y2Offset": -0.5,
            "tooltip": null,
            "clip": "x"
          },
          "encoding": {
            "color": {
              "condition": {
                "param": "highlight",
                "empty": false,
                "value": "red"
              },
              "value": "#929292"
            },
            "x": { "field": "mid", "type": "index", "band": 0 },
            "xOffset": {
              "expr": "(datum.mid - floor(datum.mid)) * pixelsPerBase",
              "type": "quantitative",
              "scale": null
            },
            "y2": { "value": 1 }
          }
        },
        {
          "name": "geneArrows",
          "params": [
            {
              "name": "highlight",
              "select": {
                "type": "point",
                "on": "pointerover",
                "clear": "mouseout"
              },
              "push": "outer",
              "persist": false
            }
          ],
          "zindex": 1,
          "mark": {
            "type": "arrow",
            "size": { "band": 0.7 },
            "headWidth": 1,
            "headAngle": 56,
            "headPlacement": "inside",
            "minStemLength": 0,
            "fill": "#55b9e9",
            "strokeWidth": 1,
            "clip": true
          },
          "encoding": {
            "order": {
              "condition": { "param": "highlight", "empty": false, "value": 1 },
              "value": 0
            },
            "stroke": {
              "condition": {
                "param": "highlight",
                "empty": false,
                "value": "red"
              },
              "value": "#8b8b8b"
            },
            "x2": { "field": "end", "band": 0 },
            "direction": {
              "field": "strand",
              "type": "nominal",
              "scale": { "domain": ["+", "-"], "range": ["forward", "reverse"] }
            },
            "tooltip": [
              { "field": "product", "title": "Product" },
              { "field": "gene", "title": "Gene" },
              { "field": "locusTag", "title": "Locus tag" },
              { "field": "start1", "title": "Start (1-based)", "format": ",d" },
              { "field": "end", "title": "End (inclusive)", "format": ",d" },
              { "field": "strand", "title": "Strand" },
              { "field": "proteinId", "title": "Protein" }
            ]
          }
        }
      ]
    }
  ]
}

Data: NCBI/NLM RefSeq NC_001416.1 (GenBank snapshot, 2023-01-11). The preparation recipe mirrors all 73 CDS features as zero-based, half-open intervals, preserving strands, product names, and overlaps; sequences and translations are omitted. Reuse follows NCBI's molecular data usage policy and disclaimer, including rights asserted by submitters.

What to notice

The initial view spans 5,000–32,000 bp. Zooming and panning reveal the rest of the 48,502 bp reference genome. Arrowheads stay inside their intervals, so genuine overlaps between adjacent genes remain visible. Hovering a gene shows its product, identifiers, strand, and source coordinates.

Hover a label or arrow to highlight that gene's label, connectors, and arrow outline in red. Hovered arrows are drawn above overlapping neighbors.

Label placement responds to the visible region and plot width. The spec measures product names, clamps their text bounds to the viewport, and uses pileup spacing to leave eight pixels between labels. Sorting those intervals by their left edge, gene midpoint, and locus tag keeps labels in a stable order when several become flush with the viewport edge.

Vertical concatenation separates the label and arrow tracks while sharing the genomic x scale. A non-zoomable index y scale gives label lanes 20-pixel steps; a band scale places arrows in two strand rows. The label viewport fills the remaining container height above the arrows.

Bottom anchoring keeps the lowest labels next to the arrows, including when the labels need less space than the viewport. When labels overflow, scroll vertically to inspect higher lanes. Changes in the lane count or viewport size preserve the scroll distance from the bottom, keeping the arrow track steady while navigating.

White rectangles interrupt leaders that pass through another label. Short connectors restore each label's own leader below its underline. Vertical rules clip only horizontally so leaders meet across the track boundary.

GenomeSpy Features

  • An index scale aligns gene intervals and label anchors while allowing zooming across the full reference.
  • arrow marks encode genomic intervals and strand direction.
  • measureText supplies label widths in pixels.
  • collect caches text measurements before viewport-dependent calculations and sorts occupied label intervals.
  • pileup assigns non-overlapping label lanes using pixel intervals and separate spacing.
  • vconcat separates the label and arrow tracks, while layer combines marks within each track.
  • A scrollable viewport with anchor: "end" keeps the label track aligned to its bottom edge.