{"spec_id":"bump-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// bump-basic: Basic Bump Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-24\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 90, right: 190, bottom: 70, left: 70 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: streaming platform rank by share of watch time, quarterly -------\nconst quarters = [\"Q1 '24\", \"Q2 '24\", \"Q3 '24\", \"Q4 '24\", \"Q1 '25\", \"Q2 '25\"];\nconst rankings = [\n  { entity: \"Netflix\", ranks: [1, 1, 1, 1, 2, 1] },\n  { entity: \"Disney+\", ranks: [2, 3, 2, 3, 1, 2] },\n  { entity: \"HBO Max\", ranks: [4, 2, 3, 2, 3, 3] },\n  { entity: \"Prime Video\", ranks: [3, 4, 4, 4, 4, 4] },\n  { entity: \"Hulu\", ranks: [5, 5, 6, 6, 6, 5] },\n  { entity: \"Apple TV+\", ranks: [6, 6, 5, 5, 5, 6] },\n];\nconst numEntities = rankings.length;\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// --- Scales -------------------------------------------------------------------\nconst x = d3.scalePoint().domain(quarters).range([0, iw]).padding(0.5);\nconst y = d3.scaleLinear().domain([1, numEntities]).range([0, ih]);\nconst color = d3.scaleOrdinal().domain(rankings.map((d) => d.entity)).range(t.palette);\n\n// --- Horizontal rank gridlines -------------------------------------------------\ng.selectAll(\".rank-grid\")\n  .data(d3.range(1, numEntities + 1))\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (r) => y(r))\n  .attr(\"y2\", (r) => y(r))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// --- Axes -----------------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x));\nconst yAxis = g\n  .append(\"g\")\n  .call(d3.axisLeft(y).tickValues(d3.range(1, numEntities + 1)).tickFormat((r) => `#${r}`));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.grid);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Bump lines -------------------------------------------------------------\nconst line = d3\n  .line()\n  .x((d, i) => x(quarters[i]))\n  .y((d) => y(d))\n  .curve(d3.curveCatmullRom.alpha(0.8));\n\ng.selectAll(\".bump-line\")\n  .data(rankings)\n  .join(\"path\")\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", (d) => color(d.entity))\n  .attr(\"stroke-width\", 3)\n  .attr(\"stroke-linecap\", \"round\")\n  .attr(\"d\", (d) => line(d.ranks));\n\n// --- Flattened {entity, period, rank} join, used by both the crossover\n// halo and the markers below ---------------------------------------------------\nconst flat = rankings.flatMap((d) => d.ranks.map((rank, i) => ({ entity: d.entity, period: quarters[i], rank })));\n\n// --- Crossover emphasis — subtle halo on the Netflix/Disney+ #1 swap ---------\nconst crossoverPeriod = \"Q1 '25\";\nconst crossoverEntities = [\"Netflix\", \"Disney+\"];\ng.selectAll(\".crossover-ring\")\n  .data(flat.filter((d) => d.period === crossoverPeriod && crossoverEntities.includes(d.entity)))\n  .join(\"circle\")\n  .attr(\"cx\", (d) => x(d.period))\n  .attr(\"cy\", (d) => y(d.rank))\n  .attr(\"r\", (d) => 12 - (d.rank - 1) * 1.1 + 7)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", (d) => color(d.entity))\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-opacity\", 0.4);\n\n// --- Markers — radius encodes rank prominence (rank 1 largest) ---------------\ng.selectAll(\".dot\")\n  .data(flat)\n  .join(\"circle\")\n  .attr(\"cx\", (d) => x(d.period))\n  .attr(\"cy\", (d) => y(d.rank))\n  .attr(\"r\", (d) => 12 - (d.rank - 1) * 1.1)\n  .attr(\"fill\", (d) => color(d.entity))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2);\n\n// --- End-of-line entity labels (redundant encoding alongside color) ----------\ng.selectAll(\".end-label\")\n  .data(rankings)\n  .join(\"text\")\n  .attr(\"x\", iw + 16)\n  .attr(\"y\", (d) => y(d.ranks[d.ranks.length - 1]))\n  .attr(\"dy\", \"0.35em\")\n  .attr(\"fill\", (d) => color(d.entity))\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => d.entity);\n\n// --- Title --------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"bump-basic · javascript · d3 · anyplot.ai\");\n\n// --- Axis titles ----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left + iw / 2)\n  .attr(\"y\", height - 16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Quarter\");\n\nsvg\n  .append(\"text\")\n  .attr(\"transform\", `translate(24, ${margin.top + ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Streaming Watch-Time Rank\");\n"}