ddb85ced5e8b6127a233b5cda5fcb1fbe2260578
max
  Wed Mar 25 04:22:06 2026 -0700
Add detailsScript trackDb mechanism for JS visualizations on bigBed details pages

Changing based on feedback from Jonathan, Chris and Brian after group
discussion. Refactored existing Claude-generated code, moving functions
into libraries.

This is the first use of ES6 modules in the kent js code. In 2026, this
should be acceptable?

New trackDb syntax: detailsScript.<plotType>.<fieldName> <jsonConfig>
The C code (bigBedClick.c) collects these settings, exports field values as JSON
(bedDetails object), and dynamically imports hgc.<plotType>.js as an ES6 module.
Fields used by detailsScript are shown in the HTML table with empty values,
filled by JavaScript.

Includes hgc.histogram.js module for drawing SVG bar chart histograms from
logfmt-encoded data (space-separated key=value pairs). Applied to both the
trexplorer and webstr tracks in the strVar supertrack.

Also adds jsonWriteJsonElement() helper to jsonWrite.c for writing parsed
jsonElement trees into a jsonWrite stream.

max, refs #36652
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git src/hg/js/hgc.histogram.js src/hg/js/hgc.histogram.js
new file mode 100644
index 00000000000..c992525ca70
--- /dev/null
+++ src/hg/js/hgc.histogram.js
@@ -0,0 +1,163 @@
+// hgc.histogram.js - ES6 module for drawing SVG histograms on hgc details pages.
+// Loaded via dynamic import() from the detailsScript trackDb mechanism.
+// bedDetails.scripts.histogram is an array of {field, value, title, xLabel}
+// where value is logfmt: space-separated key=value pairs (e.g. "3=11 4=3809")
+
+export function histogram(bedDetails) {
+    if (!bedDetails || !bedDetails.scripts || !bedDetails.scripts.histogram)
+        return;
+
+    let entries = bedDetails.scripts.histogram;
+    for (let entry of entries) {
+        if (!entry.value)
+            continue;
+
+        let parsed = parseLogfmt(entry.value);
+        if (parsed.keys.length === 0)
+            continue;
+
+        let svg = buildSvg(parsed.keys, parsed.values, entry.title, entry.xLabel);
+
+        // Find the field row by id and draw into its value cell
+        let fieldRow = document.getElementById("bfld_" + entry.field);
+        if (fieldRow) {
+            let valCell = fieldRow.cells[1];
+            if (valCell)
+                valCell.innerHTML = svg;
+            let labelCell = fieldRow.cells[0];
+            if (labelCell)
+                labelCell.innerHTML = entry.title || "Distribution";
+        }
+    }
+}
+
+function parseLogfmt(str) {
+    // Parse logfmt string into parallel arrays of keys and numeric values.
+    // Supports: key=val, "quoted key"=val, key="quoted val"
+    // Examples: '3=11 4=3809', '"My point"=20 other=5'
+    let keys = [];
+    let values = [];
+    let i = 0;
+    let len = str.length;
+    while (i < len) {
+        // skip whitespace
+        while (i < len && str[i] === " ")
+            i++;
+        if (i >= len)
+            break;
+        // read key (possibly quoted)
+        let key;
+        if (str[i] === '"') {
+            i++;
+            let end = str.indexOf('"', i);
+            if (end === -1)
+                break;
+            key = str.substring(i, end);
+            i = end + 1;
+        } else {
+            let start = i;
+            while (i < len && str[i] !== "=" && str[i] !== " ")
+                i++;
+            key = str.substring(start, i);
+        }
+        // expect '='
+        if (i >= len || str[i] !== "=")
+            break;
+        i++;
+        // read value (possibly quoted)
+        let val;
+        if (i < len && str[i] === '"') {
+            i++;
+            let vend = str.indexOf('"', i);
+            if (vend === -1)
+                break;
+            val = str.substring(i, vend);
+            i = vend + 1;
+        } else {
+            let vstart = i;
+            while (i < len && str[i] !== " ")
+                i++;
+            val = str.substring(vstart, i);
+        }
+        keys.push(key);
+        values.push(Number(val));
+    }
+    return {keys, values};
+}
+
+function buildSvg(bins, values, title, xLabel) {
+    let maxVal = Math.max(...values);
+    if (maxVal === 0)
+        return "<em>No data</em>";
+
+    // Chart dimensions
+    let barWidth = Math.max(14, Math.min(36, Math.floor(600 / bins.length)));
+    let gap = Math.max(1, Math.floor(barWidth / 8));
+    let chartHeight = 160;
+    let labelHeight = 30;
+    let topPad = 5;
+    let leftPad = 50;
+    let svgWidth = Math.max(200, leftPad + bins.length * barWidth + 10);
+    let svgHeight = chartHeight + labelHeight + topPad;
+
+    let lines = [];
+    lines.push(`<svg width="${svgWidth}" height="${svgHeight}" style="font-family: sans-serif; font-size: 11px;">`);
+
+    // Y-axis tick marks
+    let ticks = calcTicks(maxVal);
+    for (let tickVal of ticks) {
+        let y = topPad + chartHeight - (tickVal / maxVal) * chartHeight;
+        lines.push(`<line x1="${leftPad - 4}" y1="${y}" x2="${leftPad}" y2="${y}" stroke="#666"/>`);
+        lines.push(`<text x="${leftPad - 6}" y="${y + 4}" text-anchor="end" fill="#333" font-size="10">${formatNumber(tickVal)}</text>`);
+        lines.push(`<line x1="${leftPad}" y1="${y}" x2="${svgWidth - 10}" y2="${y}" stroke="#eee"/>`);
+    }
+
+    // Bars
+    let labelEvery = Math.ceil(bins.length / (svgWidth / 30));
+    for (let i = 0; i < bins.length; i++) {
+        let barHeight = (values[i] / maxVal) * chartHeight;
+        let bx = leftPad + i * barWidth + gap;
+        let by = topPad + chartHeight - barHeight;
+        let bw = barWidth - 2 * gap;
+
+        lines.push(`<rect x="${bx}" y="${by}" width="${bw}" height="${barHeight}" fill="#4682B4">`);
+        lines.push(`<title>${bins[i]}: ${values[i]}</title>`);
+        lines.push('</rect>');
+
+        if (i % labelEvery === 0 || bins.length <= 30) {
+            lines.push(`<text x="${bx + bw / 2}" y="${topPad + chartHeight + 14}" text-anchor="middle" fill="#333" font-size="10">${bins[i]}</text>`);
+        }
+    }
+
+    // Axis lines
+    lines.push(`<line x1="${leftPad}" y1="${topPad}" x2="${leftPad}" y2="${topPad + chartHeight}" stroke="#333"/>`);
+    lines.push(`<line x1="${leftPad}" y1="${topPad + chartHeight}" x2="${svgWidth - 10}" y2="${topPad + chartHeight}" stroke="#333"/>`);
+
+    if (xLabel) {
+        lines.push(`<text x="${leftPad + (svgWidth - leftPad) / 2}" y="${svgHeight - 2}" text-anchor="middle" fill="#333" font-size="11">${xLabel}</text>`);
+    }
+
+    lines.push('</svg>');
+    return lines.join("\n");
+}
+
+function calcTicks(maxVal) {
+    if (maxVal <= 5)
+        return [1, 2, 3, 4, 5].filter(v => v <= maxVal);
+    let magnitude = Math.pow(10, Math.floor(Math.log10(maxVal)));
+    let step = magnitude;
+    if (maxVal / step < 3)
+        step = magnitude / 2;
+    else if (maxVal / step > 6)
+        step = magnitude * 2;
+    let ticks = [];
+    for (let v = step; v <= maxVal; v += step)
+        ticks.push(Math.round(v));
+    return ticks;
+}
+
+function formatNumber(n) {
+    if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
+    if (n >= 1000) return (n / 1000).toFixed(1) + "K";
+    return "" + n;
+}