{"spec_id":"errorbar-asymmetric","library":"d3","language":"javascript","code":"// anyplot.ai\n// errorbar-asymmetric: Asymmetric Error Bars Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 150, right: 60, bottom: 70, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: ozone readings per station, 10th-90th percentile bounds ---------\n// Right-skewed spread (pollution spikes push the upper tail out further\n// than the lower tail) — a canonical case for asymmetric error bars.\n// Sorted by total spread (upper + lower) descending so the eye moves from\n// the least-certain reading to the most-certain one, left to right.\nconst stations = [\n  { station: \"Riverside\", median: 28, lower: 6, upper: 13 },\n  { station: \"Uptown\", median: 42, lower: 8, upper: 18 },\n  { station: \"Harbor\", median: 35, lower: 7, upper: 15 },\n  { station: \"Industrial\", median: 51, lower: 10, upper: 22 },\n  { station: \"Greenfield\", median: 22, lower: 4, upper: 9 },\n  { station: \"Lakeside\", median: 31, lower: 6, upper: 12 },\n  { station: \"Downtown\", median: 47, lower: 9, upper: 20 },\n  { station: \"Hilltop\", median: 26, lower: 5, upper: 10 },\n].sort((a, b) => b.upper + b.lower - (a.upper + a.lower));\n\n// Station with the widest asymmetric spread — the focal point of the chart.\nconst focus = stations[0];\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3\n  .select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\nconst g = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales --------------------------------------------------------------------\nconst x = d3\n  .scaleBand()\n  .domain(stations.map((d) => d.station))\n  .range([0, iw])\n  .padding(0.4);\nconst y = d3\n  .scaleLinear()\n  .domain([0, d3.max(stations, (d) => d.median + d.upper)])\n  .nice()\n  .range([ih, 0]);\n\n// --- Gridlines (y-axis only) ---------------------------------------------------\ng.append(\"g\")\n  .call(d3.axisLeft(y).tickSize(-iw).tickFormat(\"\"))\n  .call((sel) => sel.select(\".domain\").remove())\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\n\n// --- Axes ------------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).tickSize(0));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\nxAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -80)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Ozone Concentration (ppb)\");\n\n// --- Asymmetric error bars ------------------------------------------------\nconst capWidth = x.bandwidth() * 0.5;\nconst bar = g\n  .selectAll(\".errorbar\")\n  .data(stations)\n  .join(\"g\")\n  .attr(\"class\", \"errorbar\")\n  .attr(\"transform\", (d) => `translate(${x(d.station) + x.bandwidth() / 2},0)`);\n\nbar\n  .append(\"line\")\n  .attr(\"y1\", (d) => y(d.median - d.lower))\n  .attr(\"y2\", (d) => y(d.median + d.upper))\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", (d) => (d === focus ? 4 : 3));\n\nbar\n  .append(\"line\")\n  .attr(\"x1\", -capWidth / 2)\n  .attr(\"x2\", capWidth / 2)\n  .attr(\"y1\", (d) => y(d.median - d.lower))\n  .attr(\"y2\", (d) => y(d.median - d.lower))\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", (d) => (d === focus ? 4 : 3));\n\nbar\n  .append(\"line\")\n  .attr(\"x1\", -capWidth / 2)\n  .attr(\"x2\", capWidth / 2)\n  .attr(\"y1\", (d) => y(d.median + d.upper))\n  .attr(\"y2\", (d) => y(d.median + d.upper))\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", (d) => (d === focus ? 4 : 3));\n\n// Soft halo behind the focal station's marker — the visual entry point.\nbar\n  .filter((d) => d === focus)\n  .append(\"circle\")\n  .attr(\"cy\", (d) => y(d.median))\n  .attr(\"r\", 18)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.18);\n\nbar\n  .append(\"circle\")\n  .attr(\"cy\", (d) => y(d.median))\n  .attr(\"r\", (d) => (d === focus ? 11 : 9))\n  .attr(\"fill\", t.palette[0])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Callout annotation for the widest-spread station -----------------------\n// Built with d3-shape's linkVertical() — the same connector generator used\n// for tree/hierarchy edges — repurposed here to draw a genuine D3-authored\n// annotation arrow rather than a static line.\nconst focusX = x(focus.station) + x.bandwidth() / 2;\nconst focusCapY = y(focus.median + focus.upper);\nconst calloutSide = focusX < iw / 2 ? 1 : -1;\nconst labelX = focusX + calloutSide * 14;\nconst labelY = Math.max(focusCapY - 34, 14);\n\nsvg\n  .append(\"defs\")\n  .append(\"marker\")\n  .attr(\"id\", \"calloutArrow\")\n  .attr(\"viewBox\", \"0 0 10 10\")\n  .attr(\"refX\", 8)\n  .attr(\"refY\", 5)\n  .attr(\"markerWidth\", 6)\n  .attr(\"markerHeight\", 6)\n  .attr(\"orient\", \"auto-start-reverse\")\n  .append(\"path\")\n  .attr(\"d\", \"M0,0L10,5L0,10z\")\n  .attr(\"fill\", t.ink);\n\nconst calloutLink = d3\n  .linkVertical()\n  .x((p) => p.x)\n  .y((p) => p.y);\ng.append(\"path\")\n  .attr(\n    \"d\",\n    calloutLink({\n      source: { x: labelX, y: labelY + 12 },\n      target: { x: focusX, y: focusCapY - 6 },\n    }),\n  )\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"marker-end\", \"url(#calloutArrow)\");\n\ng.append(\"text\")\n  .attr(\"x\", labelX)\n  .attr(\"y\", labelY)\n  .attr(\"text-anchor\", calloutSide === 1 ? \"start\" : \"end\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(`Widest spread: −${focus.lower}/+${focus.upper} ppb (${focus.station})`);\n\n// --- Title + subtitle --------------------------------------------------------\nconst title =\n  \"Ozone Levels by Monitoring Station · errorbar-asymmetric · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.round(36 * Math.min(1, 67 / title.length));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 55)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 92)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\n    \"Points show the median reading; bars span the 10th–90th percentile range\",\n  );\n"}