{"spec_id":"sequence-logo-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// sequence-logo-basic: Sequence Logo for Motif Visualization\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) -----------------------------------------\n// 12-bp transcription-factor binding site: a highly conserved E-box core\n// (CACGTG, the canonical bHLH recognition motif) flanked by weakly-preferred\n// positions, letter order [A, C, G, T].\nconst letters = [\"A\", \"C\", \"G\", \"T\"];\nconst frequencyMatrix = [\n  [0.45, 0.2, 0.15, 0.2],\n  [0.18, 0.19, 0.45, 0.18],\n  [0.18, 0.46, 0.18, 0.18],\n  [0.15, 0.55, 0.1, 0.2],\n  [0.05, 0.85, 0.05, 0.05],\n  [0.82, 0.06, 0.06, 0.06],\n  [0.05, 0.83, 0.06, 0.06],\n  [0.05, 0.06, 0.84, 0.05],\n  [0.05, 0.05, 0.06, 0.84],\n  [0.06, 0.05, 0.83, 0.06],\n  [0.16, 0.18, 0.46, 0.2],\n  [0.46, 0.16, 0.18, 0.2],\n];\nconst positions = frequencyMatrix.map((_, i) => i + 1);\nconst MAX_BITS = 2; // log2(4) -- max information content for a 4-letter DNA alphabet\n\n// Standard nucleotide color convention (A=green, C=blue, G=orange/yellow,\n// T=red) mapped onto the closest Imprint hues -- the Semantic Exception in\n// prompts/default-style-guide.md permits this reassignment because the\n// association is a widely shared domain convention, not an arbitrary choice.\nconst letterColor = {\n  A: t.palette[0], // green\n  C: t.palette[2], // blue\n  G: t.palette[3], // ochre (stands in for orange/yellow)\n  T: t.palette[4], // red\n};\n\n// Shannon information content per position, R(i) = max_bits - entropy(i).\nconst infoContent = frequencyMatrix.map((freqs) => {\n  const entropy = freqs.reduce(\n    (sum, p) => (p > 0 ? sum - p * Math.log2(p) : sum),\n    0,\n  );\n  return MAX_BITS - entropy;\n});\n\n// Stack order per position, ascending by frequency, so the most frequent\n// letter is drawn last (on top of the stack) -- per the spec's \"most\n// frequent on top\" rule.\nconst rankedLetters = frequencyMatrix.map((freqs) =>\n  letters\n    .map((letter, idx) => ({ letter, freq: freqs[idx] }))\n    .sort((a, b) => a.freq - b.freq),\n);\n\n// One Chart.js dataset per stack rank; each bar segment's height is that\n// rank's letter frequency scaled by the position's information content.\n// Segments are invisible (transparent) -- Chart.js only supplies the\n// stacked layout math, the glyphs themselves are drawn by the plugin below.\nconst datasets = letters.map((_, rank) => ({\n  data: positions.map((_, i) => rankedLetters[i][rank].freq * infoContent[i]),\n  stack: \"logo\",\n  backgroundColor: \"transparent\",\n  borderWidth: 0,\n  categoryPercentage: 1.0,\n  barPercentage: 0.86,\n}));\n\n// --- Glyph-stacking plugin ---------------------------------------------------\n// Draws each letter as a glyph stretched (non-uniform scale) to exactly fill\n// its stack segment's rectangle, using the pixel geometry Chart.js already\n// computed for the (invisible) bar segments.\nconst sequenceLogoGlyphs = {\n  id: \"sequenceLogoGlyphs\",\n  afterDatasetsDraw(chart) {\n    const ctx = chart.ctx;\n    ctx.save();\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"alphabetic\";\n    ctx.font = '800 100px \"Arial\", \"Helvetica Neue\", sans-serif';\n    for (let rank = 0; rank < letters.length; rank++) {\n      const meta = chart.getDatasetMeta(rank);\n      for (let i = 0; i < positions.length; i++) {\n        const value = chart.data.datasets[rank].data[i];\n        if (!value) continue;\n        const el = meta.data[i];\n        if (!el) continue;\n\n        const rectWidth = el.width * 0.88;\n        const rectTop = el.y + 0.5;\n        const rectBottom = el.base - 0.5;\n        const rectHeight = rectBottom - rectTop;\n        if (rectHeight <= 0) continue;\n\n        const letter = rankedLetters[i][rank].letter;\n        const metrics = ctx.measureText(letter);\n        const naturalWidth = metrics.width;\n        const naturalHeight = metrics.actualBoundingBoxAscent || 72;\n\n        // Shadow (unlike stroke) isn't stretched by the non-uniform glyph\n        // scale below, so it gives a crisp, undistorted separation edge.\n        ctx.save();\n        ctx.shadowColor = t.pageBg;\n        ctx.shadowBlur = 3;\n        ctx.translate(el.x, rectBottom);\n        ctx.scale(rectWidth / naturalWidth, rectHeight / naturalHeight);\n        ctx.fillStyle = letterColor[letter];\n        ctx.fillText(letter, 0, 0);\n        ctx.restore();\n      }\n    }\n    ctx.restore();\n  },\n};\n\n// --- Mount --------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart --------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"bar\",\n  plugins: [sequenceLogoGlyphs],\n  data: {\n    labels: positions.map(String),\n    datasets,\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"sequence-logo-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        stacked: true,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n        title: { display: true, text: \"Position\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        stacked: true,\n        min: 0,\n        max: MAX_BITS,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Information content (bits)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n});\n"}