Clinical Audiogram — D3.js

A standardized clinical audiogram that displays hearing test results, plotting hearing threshold (dB HL) against test frequency for each ear. The y-axis is inverted so that 0 dB HL (best hearing) sits at the top and increasing hearing loss extends downward, while the x-axis is logarithmic spanning the standard audiometric frequencies (125 Hz to 8 kHz). Thresholds are marked with the conventional symbols — a circle (O) for the right ear in red and a cross (X) for the left ear in blue — and connected per ear, with shaded horizontal bands indicating severity of hearing loss (normal, mild, moderate, severe, profound).

Clinical Audiogram rendered with D3.js

JavaScript source (D3.js)

// anyplot.ai
// audiogram-clinical: Clinical Audiogram
// Library: d3 7.9.0 | JavaScript 22.22.3
// Quality: 93/100 | Created: 2026-06-15

//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 80, right: 165, bottom: 88, left: 88 };
const iw = width  - margin.left - margin.right;
const ih = height - margin.top  - margin.bottom;

// --- Data: noise-induced high-frequency sensorineural hearing loss -----------
const frequencies = [125, 250, 500, 1000, 2000, 4000, 8000];
const freqLabel   = { 125: "125", 250: "250", 500: "500", 1000: "1k",
                      2000: "2k", 4000: "4k", 8000: "8k" };

// Right ear: gradual sensorineural notch peaking at 4 kHz (O markers, red)
const rightEar = [
  { freq: 125, db: 10 }, { freq: 250, db: 15 }, { freq: 500, db: 15 },
  { freq: 1000, db: 20 }, { freq: 2000, db: 35 }, { freq: 4000, db: 65 },
  { freq: 8000, db: 70 },
];

// Left ear: similar sensorineural pattern, slightly worse (X markers, blue)
const leftEar = [
  { freq: 125, db: 15 }, { freq: 250, db: 15 }, { freq: 500, db: 20 },
  { freq: 1000, db: 25 }, { freq: 2000, db: 40 }, { freq: 4000, db: 70 },
  { freq: 8000, db: 75 },
];

// ISO 8253 severity bands — Imprint-derived fills, 1.5× opacity lift for dark theme
const isDark = window.ANYPLOT_THEME === "dark";
const bands = [
  { label: "Normal",      lo: -10, hi: 25,
    fill: isDark ? "rgba(0,158,115,0.15)"   : "rgba(0,158,115,0.10)"   },
  { label: "Mild",        lo: 25,  hi: 40,
    fill: isDark ? "rgba(153,179,20,0.18)"  : "rgba(153,179,20,0.12)"  },
  { label: "Moderate",    lo: 40,  hi: 55,
    fill: isDark ? "rgba(189,130,51,0.21)"  : "rgba(189,130,51,0.14)"  },
  { label: "Mod. Severe", lo: 55,  hi: 70,
    fill: isDark ? "rgba(221,204,119,0.27)" : "rgba(221,204,119,0.18)" },
  { label: "Severe",      lo: 70,  hi: 90,
    fill: isDark ? "rgba(174,48,48,0.21)"   : "rgba(174,48,48,0.14)"   },
  { label: "Profound",    lo: 90,  hi: 120,
    fill: isDark ? "rgba(174,48,48,0.39)"   : "rgba(174,48,48,0.26)"   },
];

// Semantic colors: ISO 8253 clinical convention (red = right, blue = left)
const RIGHT_COLOR = "#AE3030";  // Imprint matte red — right ear convention
const LEFT_COLOR  = "#4467A3";  // Imprint blue     — left ear convention

// --- SVG mount --------------------------------------------------------------
const svg = d3.select("#container")
  .append("svg")
  .attr("width", width)
  .attr("height", height);

const g = svg.append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

// --- Scales -----------------------------------------------------------------
const x = d3.scaleLog()
  .domain([125, 8000])
  .range([0, iw]);

// Inverted Y: -10 dB at top (y = 0), 120 dB at bottom (y = ih)
const y = d3.scaleLinear()
  .domain([-10, 120])
  .range([0, ih]);

// --- Severity bands ---------------------------------------------------------
bands.forEach(band => {
  g.append("rect")
    .attr("x", 0)
    .attr("y", y(band.lo))
    .attr("width", iw)
    .attr("height", y(band.hi) - y(band.lo))
    .attr("fill", band.fill);
});

// --- Gridlines --------------------------------------------------------------
// Horizontal: every 10 dB (full y-range)
d3.range(-10, 121, 10).forEach(db => {
  g.append("line")
    .attr("x1", 0).attr("x2", iw)
    .attr("y1", y(db)).attr("y2", y(db))
    .attr("stroke", t.grid)
    .attr("stroke-width", 0.8);
});

// Vertical: at each standard audiometric frequency
frequencies.forEach(freq => {
  g.append("line")
    .attr("x1", x(freq)).attr("x2", x(freq))
    .attr("y1", 0).attr("y2", ih)
    .attr("stroke", t.grid)
    .attr("stroke-width", 0.8);
});

// --- Plot border (all 4 sides — clinical audiogram standard) ----------------
g.append("rect")
  .attr("x", 0).attr("y", 0)
  .attr("width", iw).attr("height", ih)
  .attr("fill", "none")
  .attr("stroke", t.inkSoft)
  .attr("stroke-width", 1);

// --- Axes -------------------------------------------------------------------
const xAxis = g.append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(
    d3.axisBottom(x)
      .tickValues(frequencies)
      .tickFormat(f => freqLabel[f])
      .tickSize(6)
  );
xAxis.selectAll("text")
  .attr("fill", t.inkSoft)
  .style("font-size", "14px");
xAxis.selectAll(".tick line").attr("stroke", t.inkSoft);
xAxis.select(".domain").remove();

const yAxis = g.append("g")
  .call(
    d3.axisLeft(y)
      .tickValues(d3.range(-10, 121, 10))
      .tickFormat(d3.format("d"))
      .tickSize(6)
  );
yAxis.selectAll("text")
  .attr("fill", t.inkSoft)
  .style("font-size", "14px");
yAxis.selectAll(".tick line").attr("stroke", t.inkSoft);
yAxis.select(".domain").remove();

// --- Ear connecting lines ---------------------------------------------------
const lineGen = d3.line()
  .x(d => x(d.freq))
  .y(d => y(d.db));

// Right ear: solid red line
g.append("path")
  .datum(rightEar)
  .attr("d", lineGen)
  .attr("fill", "none")
  .attr("stroke", RIGHT_COLOR)
  .attr("stroke-width", 2.5)
  .attr("stroke-linejoin", "round");

// Left ear: dashed blue line (accepted clinical convention to distinguish ears)
g.append("path")
  .datum(leftEar)
  .attr("d", lineGen)
  .attr("fill", "none")
  .attr("stroke", LEFT_COLOR)
  .attr("stroke-width", 2.5)
  .attr("stroke-dasharray", "7,4")
  .attr("stroke-linejoin", "round");

// --- Markers ----------------------------------------------------------------
// Right ear: open circles (O) — elevatedBg fill avoids blending into dark severity bands
const circleFill = isDark ? t.elevatedBg : t.pageBg;
g.selectAll(".m-right")
  .data(rightEar)
  .join("circle")
  .attr("class", "m-right")
  .attr("cx", d => x(d.freq))
  .attr("cy", d => y(d.db))
  .attr("r", 9)
  .attr("fill", circleFill)
  .attr("stroke", RIGHT_COLOR)
  .attr("stroke-width", 2.5);

// Left ear: X markers — d3.symbolCross rotated 45° gives the correct × shape
const xSymbol = d3.symbol().type(d3.symbolCross).size(320);
g.selectAll(".m-left")
  .data(leftEar)
  .join("path")
  .attr("class", "m-left")
  .attr("transform", d => `translate(${x(d.freq)},${y(d.db)}) rotate(45)`)
  .attr("d", xSymbol)
  .attr("fill", LEFT_COLOR)
  .attr("stroke", "none");

// --- Axis labels ------------------------------------------------------------
svg.append("text")
  .attr("x", margin.left + iw / 2)
  .attr("y", height - 18)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .text("Frequency (Hz)");

svg.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -(margin.top + ih / 2))
  .attr("y", 24)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .text("Hearing Level (dB HL)");

// --- Severity band labels (right margin) ------------------------------------
bands.forEach(band => {
  svg.append("text")
    .attr("x", margin.left + iw + 10)
    .attr("y", margin.top + y(band.lo) + (y(band.hi) - y(band.lo)) / 2)
    .attr("dy", "0.35em")
    .attr("fill", t.inkSoft)
    .style("font-size", "14px")
    .text(band.label);
});

// --- Legend (below title, above plot area) ----------------------------------
const lgY  = 60;
const lgX1 = margin.left + iw / 2 - 140;
const lgX2 = lgX1 + 175;

// Right ear legend entry
svg.append("line")
  .attr("x1", lgX1).attr("y1", lgY)
  .attr("x2", lgX1 + 34).attr("y2", lgY)
  .attr("stroke", RIGHT_COLOR)
  .attr("stroke-width", 2.5);
svg.append("circle")
  .attr("cx", lgX1 + 17).attr("cy", lgY).attr("r", 7)
  .attr("fill", circleFill)
  .attr("stroke", RIGHT_COLOR)
  .attr("stroke-width", 2.5);
svg.append("text")
  .attr("x", lgX1 + 44).attr("y", lgY).attr("dy", "0.35em")
  .attr("fill", t.ink)
  .style("font-size", "14px")
  .text("Right Ear (O)");

// Left ear legend entry
svg.append("line")
  .attr("x1", lgX2).attr("y1", lgY)
  .attr("x2", lgX2 + 34).attr("y2", lgY)
  .attr("stroke", LEFT_COLOR)
  .attr("stroke-width", 2.5)
  .attr("stroke-dasharray", "7,4");
svg.append("path")
  .attr("transform", `translate(${lgX2 + 17},${lgY}) rotate(45)`)
  .attr("d", d3.symbol().type(d3.symbolCross).size(220))
  .attr("fill", LEFT_COLOR)
  .attr("stroke", "none");
svg.append("text")
  .attr("x", lgX2 + 44).attr("y", lgY).attr("dy", "0.35em")
  .attr("fill", t.ink)
  .style("font-size", "14px")
  .text("Left Ear (X)");

// --- Title ------------------------------------------------------------------
svg.append("text")
  .attr("x", width / 2)
  .attr("y", 36)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "22px")
  .style("font-weight", "600")
  .text("audiogram-clinical · javascript · d3 · anyplot.ai");

Part of Clinical Audiogram on anyplot.ai.

Other implementations