{"spec_id":"slope-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// slope-basic: Basic Slope Chart (Slopegraph)\n// Library: d3 7.9.0 | JavaScript 22.23.1\n// Quality: 92/100 | Updated: 2026-07-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\nconst margin = { top: 175, right: 260, bottom: 50, left: 260 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: employee satisfaction score (0-100) by department, 2019 vs 2024 survey\nconst data = [\n  { department: \"Engineering\", year2019: 72, year2024: 81 },\n  { department: \"Sales\", year2019: 68, year2024: 61 },\n  { department: \"Marketing\", year2019: 75, year2024: 79 },\n  { department: \"HR\", year2019: 80, year2024: 74 },\n  { department: \"Finance\", year2019: 65, year2024: 70 },\n  { department: \"Customer Support\", year2019: 58, year2024: 52 },\n  { department: \"Operations\", year2019: 70, year2024: 77 },\n  { department: \"Product\", year2019: 77, year2024: 83 },\n  { department: \"Legal\", year2019: 82, year2024: 80 },\n  { department: \"IT\", year2019: 63, year2024: 69 },\n];\n\n// --- Scale --------------------------------------------------------------------\nconst allValues = data.flatMap((d) => [d.year2019, d.year2024]);\nconst y = d3\n  .scaleLinear()\n  .domain([d3.min(allValues) - 5, d3.max(allValues) + 5])\n  .nice()\n  .range([ih, 0]);\n\nconst increaseColor = t.palette[0]; // brand green — always first series, doubles as \"improved\"\nconst decreaseColor = t.palette[4]; // matte red — Imprint semantic anchor for decline\nconst colorOf = (d) => (d.year2024 >= d.year2019 ? increaseColor : decreaseColor);\n\n// --- Storytelling focal point: call out the single largest mover ---------------\nconst standout = d3.greatest(data, (d) => Math.abs(d.year2024 - d.year2019));\n\n// --- Label decluttering: keep the true data point, nudge stacked labels apart --\n// Slope charts pack many labels along two narrow columns; a plain scaleLinear\n// position collides whenever two entities share a close value. Sort by position,\n// enforce a minimum gap top-to-bottom, then re-settle from the bottom if the\n// pass pushed the last label past the plot bounds.\nfunction declutter(points, minGap) {\n  const sorted = points.slice().sort((a, b) => a.y - b.y);\n  for (let i = 1; i < sorted.length; i++) {\n    if (sorted[i].y - sorted[i - 1].y < minGap) sorted[i].y = sorted[i - 1].y + minGap;\n  }\n  const overflow = sorted[sorted.length - 1].y - ih;\n  if (overflow > 0) {\n    for (let i = sorted.length - 1; i >= 0; i--) {\n      sorted[i].y -= overflow;\n      if (i > 0 && sorted[i].y - sorted[i - 1].y >= minGap) break;\n    }\n  }\n  return sorted;\n}\n\nconst minGap = 34;\nconst leftLabels = declutter(\n  data.map((d) => ({ d, trueY: y(d.year2019), y: y(d.year2019) })),\n  minGap\n);\nconst rightLabels = declutter(\n  data.map((d) => ({ d, trueY: y(d.year2024), y: y(d.year2024) })),\n  minGap\n);\n\n// --- SVG mount ------------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Vertical axes: the two survey years ----------------------------------------\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", 0).attr(\"y1\", 0).attr(\"y2\", ih)\n  .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1.5);\ng.append(\"line\")\n  .attr(\"x1\", iw).attr(\"x2\", iw).attr(\"y1\", 0).attr(\"y2\", ih)\n  .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1.5);\n\ng.append(\"text\")\n  .attr(\"x\", 0).attr(\"y\", -20).attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"20px\").style(\"font-weight\", \"600\")\n  .text(\"2019 Survey\");\ng.append(\"text\")\n  .attr(\"x\", iw).attr(\"y\", -20).attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"20px\").style(\"font-weight\", \"600\")\n  .text(\"2024 Survey\");\n\n// --- Slope lines + endpoint markers ----------------------------------------------\ng.selectAll(\".slope-line\")\n  .data(data)\n  .join(\"line\")\n  .attr(\"class\", \"slope-line\")\n  .attr(\"x1\", 0).attr(\"y1\", (d) => y(d.year2019))\n  .attr(\"x2\", iw).attr(\"y2\", (d) => y(d.year2024))\n  .attr(\"stroke\", colorOf)\n  .attr(\"stroke-width\", (d) => (d === standout ? 5 : 3))\n  .attr(\"opacity\", (d) => (d === standout ? 1 : 0.85));\n\ng.selectAll(\".start-point\")\n  .data(data)\n  .join(\"circle\")\n  .attr(\"class\", \"start-point\")\n  .attr(\"cx\", 0).attr(\"cy\", (d) => y(d.year2019))\n  .attr(\"r\", (d) => (d === standout ? 8 : 6)).attr(\"fill\", colorOf);\n\ng.selectAll(\".end-point\")\n  .data(data)\n  .join(\"circle\")\n  .attr(\"class\", \"end-point\")\n  .attr(\"cx\", iw).attr(\"cy\", (d) => y(d.year2024))\n  .attr(\"r\", (d) => (d === standout ? 8 : 6)).attr(\"fill\", colorOf);\n\n// --- Focal-point annotation: label the standout mover at its line midpoint -----\nconst standoutDelta = standout.year2024 - standout.year2019;\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", (y(standout.year2019) + y(standout.year2024)) / 2 - 14)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", colorOf(standout)).style(\"font-size\", \"16px\").style(\"font-weight\", \"700\")\n  .text(`${standout.department}: ${standoutDelta > 0 ? \"+\" : \"\"}${standoutDelta} (largest ${standoutDelta > 0 ? \"gain\" : \"decline\"})`);\n\n// --- Leader ticks where a label was nudged off its true position -----------------\ng.selectAll(\".leader-left\")\n  .data(leftLabels.filter((p) => Math.abs(p.y - p.trueY) > 1))\n  .join(\"line\")\n  .attr(\"class\", \"leader-left\")\n  .attr(\"x1\", 0).attr(\"y1\", (p) => p.trueY)\n  .attr(\"x2\", -16).attr(\"y2\", (p) => p.y)\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\ng.selectAll(\".leader-right\")\n  .data(rightLabels.filter((p) => Math.abs(p.y - p.trueY) > 1))\n  .join(\"line\")\n  .attr(\"class\", \"leader-right\")\n  .attr(\"x1\", iw).attr(\"y1\", (p) => p.trueY)\n  .attr(\"x2\", iw + 16).attr(\"y2\", (p) => p.y)\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\n// --- Endpoint labels ---------------------------------------------------------------\ng.selectAll(\".label-left\")\n  .data(leftLabels)\n  .join(\"text\")\n  .attr(\"class\", \"label-left\")\n  .attr(\"x\", -20).attr(\"y\", (p) => p.y)\n  .attr(\"dy\", \"0.35em\").attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"17px\")\n  .text((p) => `${p.d.department} · ${p.d.year2019}`);\n\ng.selectAll(\".label-right\")\n  .data(rightLabels)\n  .join(\"text\")\n  .attr(\"class\", \"label-right\")\n  .attr(\"x\", iw + 20).attr(\"y\", (p) => p.y)\n  .attr(\"dy\", \"0.35em\").attr(\"text-anchor\", \"start\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"17px\")\n  .text((p) => `${p.d.year2024} · ${p.d.department}`);\n\n// --- Legend: line direction -----------------------------------------------------\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${width / 2 - 110}, 118)`);\nlegend.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", 26).attr(\"y1\", 0).attr(\"y2\", 0)\n  .attr(\"stroke\", increaseColor).attr(\"stroke-width\", 3);\nlegend.append(\"text\")\n  .attr(\"x\", 34).attr(\"y\", 5)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(\"Improved\");\nlegend.append(\"line\")\n  .attr(\"x1\", 140).attr(\"x2\", 166).attr(\"y1\", 0).attr(\"y2\", 0)\n  .attr(\"stroke\", decreaseColor).attr(\"stroke-width\", 3);\nlegend.append(\"text\")\n  .attr(\"x\", 174).attr(\"y\", 5)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(\"Declined\");\n\n// --- Title + subtitle ------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 60).attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"slope-basic · javascript · d3 · anyplot.ai\");\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 88).attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\")\n  .text(\"Employee Satisfaction Score (0-100 scale)\");\n"}