|
| 1 | +// anyplot.ai |
| 2 | +// dumbbell-basic: Basic Dumbbell Chart |
| 3 | +// Library: d3 7.9.0 | JavaScript 22.23.1 |
| 4 | +// Quality: 91/100 | Created: 2026-06-30 |
| 5 | + |
| 6 | +const t = window.ANYPLOT_TOKENS; |
| 7 | +const { width, height } = window.ANYPLOT_SIZE; |
| 8 | + |
| 9 | +const margin = { top: 95, right: 130, bottom: 90, left: 175 }; |
| 10 | +const iw = width - margin.left - margin.right; |
| 11 | +const ih = height - margin.top - margin.bottom; |
| 12 | + |
| 13 | +// Employee satisfaction scores (0–100) before and after flexible work policy |
| 14 | +const rawData = [ |
| 15 | + { dept: "Research", before: 57, after: 79 }, |
| 16 | + { dept: "Engineering", before: 72, after: 91 }, |
| 17 | + { dept: "Product", before: 63, after: 81 }, |
| 18 | + { dept: "IT Support", before: 65, after: 82 }, |
| 19 | + { dept: "Design", before: 68, after: 84 }, |
| 20 | + { dept: "Operations", before: 71, after: 83 }, |
| 21 | + { dept: "Marketing", before: 60, after: 71 }, |
| 22 | + { dept: "Customer Success", before: 66, after: 76 }, |
| 23 | + { dept: "HR", before: 75, after: 83 }, |
| 24 | + { dept: "Finance", before: 62, after: 69 }, |
| 25 | +]; |
| 26 | + |
| 27 | +// Sort by improvement (largest gap at top) |
| 28 | +const data = rawData.slice().sort((a, b) => (b.after - b.before) - (a.after - a.before)); |
| 29 | +data.forEach(d => { d.delta = d.after - d.before; }); |
| 30 | + |
| 31 | +const svg = d3.select("#container").append("svg") |
| 32 | + .attr("width", width) |
| 33 | + .attr("height", height); |
| 34 | + |
| 35 | +// --- D3 SVG defs: linear gradient for connecting lines (green → lavender) --- |
| 36 | +// Defined in SVG user-space so position tracks the data scale exactly |
| 37 | +const defs = svg.append("defs"); |
| 38 | +const grad = defs.append("linearGradient") |
| 39 | + .attr("id", "line-grad") |
| 40 | + .attr("gradientUnits", "userSpaceOnUse") |
| 41 | + .attr("x1", margin.left) |
| 42 | + .attr("x2", margin.left + iw) |
| 43 | + .attr("y1", 0) |
| 44 | + .attr("y2", 0); |
| 45 | +grad.append("stop").attr("offset", "0%") |
| 46 | + .attr("stop-color", t.palette[0]).attr("stop-opacity", 0.65); |
| 47 | +grad.append("stop").attr("offset", "100%") |
| 48 | + .attr("stop-color", t.palette[1]).attr("stop-opacity", 0.65); |
| 49 | + |
| 50 | +const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`); |
| 51 | + |
| 52 | +// Scales |
| 53 | +const x = d3.scaleLinear().domain([50, 100]).range([0, iw]); |
| 54 | +const y = d3.scaleBand() |
| 55 | + .domain(data.map(d => d.dept)) |
| 56 | + .range([0, ih]) |
| 57 | + .padding(0.4); |
| 58 | + |
| 59 | +// Vertical gridlines |
| 60 | +g.selectAll(".vgrid") |
| 61 | + .data(x.ticks(6)) |
| 62 | + .join("line") |
| 63 | + .attr("x1", d => x(d)) |
| 64 | + .attr("x2", d => x(d)) |
| 65 | + .attr("y1", 0) |
| 66 | + .attr("y2", ih) |
| 67 | + .attr("stroke", t.grid) |
| 68 | + .attr("stroke-width", 1); |
| 69 | + |
| 70 | +// X-axis — d3.format("d") produces clean integers with no decimal noise |
| 71 | +const xAxisG = g.append("g") |
| 72 | + .attr("transform", `translate(0,${ih})`) |
| 73 | + .call(d3.axisBottom(x).ticks(6).tickSize(0).tickFormat(d3.format("d"))); |
| 74 | + |
| 75 | +xAxisG.select(".domain").attr("stroke", t.inkSoft); |
| 76 | +xAxisG.selectAll(".tick text") |
| 77 | + .attr("fill", t.inkSoft) |
| 78 | + .style("font-size", "14px") |
| 79 | + .attr("dy", "1.3em"); |
| 80 | +xAxisG.selectAll(".tick line").remove(); |
| 81 | + |
| 82 | +// Y-axis (no domain line — categories self-label) |
| 83 | +const yAxisG = g.append("g") |
| 84 | + .call(d3.axisLeft(y).tickSize(0)); |
| 85 | + |
| 86 | +yAxisG.select(".domain").remove(); |
| 87 | +yAxisG.selectAll(".tick text") |
| 88 | + .attr("fill", t.inkSoft) |
| 89 | + .style("font-size", "14px") |
| 90 | + .attr("dx", "-0.6em"); |
| 91 | + |
| 92 | +// Connecting lines — gradient stroke encodes before→after direction |
| 93 | +g.selectAll(".dumbbell-line") |
| 94 | + .data(data) |
| 95 | + .join("line") |
| 96 | + .attr("x1", d => x(d.before)) |
| 97 | + .attr("x2", d => x(d.after)) |
| 98 | + .attr("y1", d => y(d.dept) + y.bandwidth() / 2) |
| 99 | + .attr("y2", d => y(d.dept) + y.bandwidth() / 2) |
| 100 | + .attr("stroke", "url(#line-grad)") |
| 101 | + .attr("stroke-width", 3); |
| 102 | + |
| 103 | +// Before dots (Imprint palette[0] — brand green) |
| 104 | +g.selectAll(".dot-before") |
| 105 | + .data(data) |
| 106 | + .join("circle") |
| 107 | + .attr("cx", d => x(d.before)) |
| 108 | + .attr("cy", d => y(d.dept) + y.bandwidth() / 2) |
| 109 | + .attr("r", 10) |
| 110 | + .attr("fill", t.palette[0]) |
| 111 | + .attr("stroke", t.pageBg) |
| 112 | + .attr("stroke-width", 2); |
| 113 | + |
| 114 | +// After dots (Imprint palette[1] — lavender) |
| 115 | +g.selectAll(".dot-after") |
| 116 | + .data(data) |
| 117 | + .join("circle") |
| 118 | + .attr("cx", d => x(d.after)) |
| 119 | + .attr("cy", d => y(d.dept) + y.bandwidth() / 2) |
| 120 | + .attr("r", 10) |
| 121 | + .attr("fill", t.palette[1]) |
| 122 | + .attr("stroke", t.pageBg) |
| 123 | + .attr("stroke-width", 2); |
| 124 | + |
| 125 | +// Delta labels — d3.format("+d") shows signed improvement for each row |
| 126 | +const deltaFmt = d3.format("+d"); |
| 127 | +g.selectAll(".delta-label") |
| 128 | + .data(data) |
| 129 | + .join("text") |
| 130 | + .attr("x", iw + 14) |
| 131 | + .attr("y", d => y(d.dept) + y.bandwidth() / 2 + 5) |
| 132 | + .attr("fill", (d, i) => i < 2 ? t.palette[1] : t.inkSoft) |
| 133 | + .style("font-size", "14px") |
| 134 | + .style("font-weight", (d, i) => i < 2 ? "700" : "400") |
| 135 | + .text(d => deltaFmt(d.delta) + " pts"); |
| 136 | + |
| 137 | +// Callout annotations for top 2 improvements — placed in the gap below each band |
| 138 | +const annLabels = ["▲ Highest gain", "▲ Runner-up"]; |
| 139 | +data.slice(0, 2).forEach((d, i) => { |
| 140 | + const xMid = (x(d.before) + x(d.after)) / 2; |
| 141 | + const yAnn = y(d.dept) + y.bandwidth() + 6; |
| 142 | + g.append("text") |
| 143 | + .attr("x", xMid) |
| 144 | + .attr("y", yAnn + 10) |
| 145 | + .attr("text-anchor", "middle") |
| 146 | + .attr("fill", t.amber) |
| 147 | + .style("font-size", "12px") |
| 148 | + .style("font-weight", "600") |
| 149 | + .text(annLabels[i]); |
| 150 | +}); |
| 151 | + |
| 152 | +// X-axis label |
| 153 | +g.append("text") |
| 154 | + .attr("x", iw / 2) |
| 155 | + .attr("y", ih + 62) |
| 156 | + .attr("text-anchor", "middle") |
| 157 | + .attr("fill", t.inkSoft) |
| 158 | + .style("font-size", "14px") |
| 159 | + .text("Satisfaction Score (0–100)"); |
| 160 | + |
| 161 | +// Legend (horizontal, centered below title in top margin) |
| 162 | +const legendItems = [ |
| 163 | + { label: "Before Policy", color: t.palette[0] }, |
| 164 | + { label: "After Policy", color: t.palette[1] }, |
| 165 | +]; |
| 166 | +const legendSpacing = 170; |
| 167 | +const legendStartX = (width - legendItems.length * legendSpacing) / 2; |
| 168 | +const legendY = 72; |
| 169 | + |
| 170 | +legendItems.forEach((item, i) => { |
| 171 | + const lx = legendStartX + i * legendSpacing; |
| 172 | + svg.append("circle") |
| 173 | + .attr("cx", lx + 10).attr("cy", legendY) |
| 174 | + .attr("r", 10) |
| 175 | + .attr("fill", item.color) |
| 176 | + .attr("stroke", t.pageBg) |
| 177 | + .attr("stroke-width", 1.5); |
| 178 | + svg.append("text") |
| 179 | + .attr("x", lx + 26).attr("y", legendY + 5) |
| 180 | + .attr("fill", t.inkSoft) |
| 181 | + .style("font-size", "13px") |
| 182 | + .text(item.label); |
| 183 | +}); |
| 184 | + |
| 185 | +// Title — font-size scaled for long descriptive title |
| 186 | +const title = "Employee Satisfaction · dumbbell-basic · javascript · d3 · anyplot.ai"; |
| 187 | +const titleFontSize = title.length > 67 ? Math.round(22 * 67 / title.length) : 22; |
| 188 | + |
| 189 | +svg.append("text") |
| 190 | + .attr("x", width / 2) |
| 191 | + .attr("y", 42) |
| 192 | + .attr("text-anchor", "middle") |
| 193 | + .attr("fill", t.ink) |
| 194 | + .style("font-size", `${titleFontSize}px`) |
| 195 | + .style("font-weight", "600") |
| 196 | + .text(title); |
0 commit comments