{"spec_id":"radar-innovation-timeline","library":"d3","language":"javascript","code":"// anyplot.ai\n// radar-innovation-timeline: Innovation Radar with Time-Horizon Rings\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-26\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Layout: upper half-circle (180°), flat diameter down, dome up --------\nconst cx = width / 2;\nconst rInner = 150;\nconst rOuter = 540;\nconst cy = 110 + rOuter;\nconst ringStep = (rOuter - rInner) / 4;\nconst ringBounds = d3.range(5).map((i) => rInner + i * ringStep);\n\n// --- Data: time-horizon rings (inner -> outer) ------------------------------\nconst rings = [\n  { name: \"Now\", inner: ringBounds[0], outer: ringBounds[1] },\n  { name: \"Near-Term\", inner: ringBounds[1], outer: ringBounds[2] },\n  { name: \"Mid-Term\", inner: ringBounds[2], outer: ringBounds[3] },\n  { name: \"Future\", inner: ringBounds[3], outer: ringBounds[4] },\n];\n\n// --- Data: thematic sectors (left -> right across the dome) ----------------\nconst sectors = [\n  { name: \"AI & ML\", start: -90, end: -45, symbol: d3.symbolCircle },\n  { name: \"Infrastructure\", start: -45, end: 0, symbol: d3.symbolSquare },\n  { name: \"Sustainability\", start: 0, end: 45, symbol: d3.symbolTriangle },\n  { name: \"Biotech\", start: 45, end: 90, symbol: d3.symbolDiamond },\n];\nsectors.forEach((s) => (s.mid = (s.start + s.end) / 2));\n\n// Sector -> palette color, via d3's ordinal scale (idiomatic categorical mapping).\nconst colorScale = d3.scaleOrdinal(\n  sectors.map((s) => s.name),\n  sectors.map((_, i) => t.palette[i]),\n);\n\n// --- Data: innovations placed by sector + ring, jittered within the cell ---\n// Every item within a sector gets a distinct angular offset (see OFFSETS\n// below) even across different rings — reusing an offset would put two items\n// on the exact same ray from the center, and the label-collision pass below\n// pushes labels outward *along their own ray*, so a repeated offset would\n// drive an inner item's label straight into an outer item on the same ray.\nconst items = [\n  { name: \"LLM Copilots\", sector: 0, ring: 0, frac: 0.35 },\n  { name: \"Retrieval-Augmented Search\", sector: 0, ring: 0, frac: 0.65 },\n  { name: \"Multi-Agent Orchestration\", sector: 0, ring: 1, frac: 0.35 },\n  { name: \"On-Device Small Models\", sector: 0, ring: 1, frac: 0.65 },\n  { name: \"Neuromorphic Chips\", sector: 0, ring: 2, frac: 0.5 },\n  { name: \"Artificial General Intelligence\", sector: 0, ring: 3, frac: 0.55 },\n\n  { name: \"Serverless Data Platforms\", sector: 1, ring: 0, frac: 0.5 },\n  { name: \"Edge Computing Mesh\", sector: 1, ring: 1, frac: 0.35 },\n  { name: \"Confidential Computing\", sector: 1, ring: 1, frac: 0.65 },\n  { name: \"Quantum-Safe Networking\", sector: 1, ring: 2, frac: 0.5 },\n  { name: \"6G Standards\", sector: 1, ring: 3, frac: 0.4 },\n  { name: \"Space-Based Data Centers\", sector: 1, ring: 3, frac: 0.68 },\n\n  { name: \"Grid-Scale Battery Storage\", sector: 2, ring: 0, frac: 0.5 },\n  { name: \"Green Hydrogen Fuel\", sector: 2, ring: 1, frac: 0.35 },\n  { name: \"Direct Air Capture\", sector: 2, ring: 1, frac: 0.65 },\n  { name: \"Solar Perovskite Cells\", sector: 2, ring: 2, frac: 0.4 },\n  { name: \"Circular Packaging Materials\", sector: 2, ring: 2, frac: 0.68 },\n  { name: \"Fusion Power Plants\", sector: 2, ring: 3, frac: 0.5 },\n\n  { name: \"mRNA Therapeutics\", sector: 3, ring: 0, frac: 0.5 },\n  { name: \"CRISPR Gene Editing\", sector: 3, ring: 1, frac: 0.35 },\n  { name: \"Organ-on-a-Chip Testing\", sector: 3, ring: 1, frac: 0.65 },\n  { name: \"Synthetic Biology Platforms\", sector: 3, ring: 2, frac: 0.5 },\n  { name: \"Lab-Grown Organ Transplants\", sector: 3, ring: 3, frac: 0.4 },\n  { name: \"Longevity Gene Therapies\", sector: 3, ring: 3, frac: 0.68 },\n];\n\nconst OFFSETS = [-18, -11, -4, 4, 11, 18];\nsectors.forEach((_, s) => items.filter((d) => d.sector === s).forEach((d, k) => (d.offset = OFFSETS[k])));\n\n// d3.arc's angle convention (0 = up, clockwise) matches this helper directly.\nconst toRad = (deg) => (deg * Math.PI) / 180;\nconst polar = (deg, r) => [cx + r * Math.sin(toRad(deg)), cy - r * Math.cos(toRad(deg))];\n\nitems.forEach((d) => {\n  const ring = rings[d.ring];\n  d.angle = sectors[d.sector].mid + d.offset;\n  d.radius = d3.scaleLinear().domain([0, 1]).range([ring.inner, ring.outer])(d.frac);\n  [d.x, d.y] = polar(d.angle, d.radius);\n  // Nudges applied by the collision-avoidance pass below: labelPush moves the\n  // label further from center along its ray; labelAngleAdj additionally\n  // rotates it a few degrees — signed per-conflict (see `nudge` below) so it\n  // moves away from whatever it's actually overlapping, not a fixed sector\n  // direction (two items on the same side of a sector's mid-line must be\n  // free to spread apart from *each other*, not both toward the same edge).\n  d.labelPush = 0;\n  d.labelAngleAdj = 0;\n});\n\n// A label's marker anchors its data point; the label text itself may drift\n// further out along the same angle (never sideways into a neighbor's arc).\nconst labelSide = (angle) => (angle <= -18 ? -1 : angle >= 18 ? 1 : 0);\nconst labelPos = (d) => {\n  const side = labelSide(d.angle);\n  const [px, py] = polar(d.angle + d.labelAngleAdj, d.radius + d.labelPush);\n  return {\n    x: px + side * 12,\n    y: py + (side !== 0 ? 4 : -16),\n    anchor: side < 0 ? \"end\" : side > 0 ? \"start\" : \"middle\",\n  };\n};\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst chart = svg.append(\"g\");\n\n// --- Ring background bands (alternating, purely structural) -----------------\nconst bandArc = d3.arc().startAngle(-Math.PI / 2).endAngle(Math.PI / 2);\nchart\n  .selectAll(\"path.band\")\n  .data(rings)\n  .join(\"path\")\n  .attr(\"class\", \"band\")\n  .attr(\"transform\", `translate(${cx},${cy})`)\n  .attr(\"d\", (d) => bandArc.innerRadius(d.inner).outerRadius(d.outer)())\n  .attr(\"fill\", t.ink)\n  .attr(\"fill-opacity\", (d, i) => (i % 2 === 0 ? 0.05 : 0));\n\n// --- Ring boundary lines ------------------------------------------------------\nconst boundaryArc = d3.arc().startAngle(-Math.PI / 2).endAngle(Math.PI / 2);\nconst boundaries = ringBounds;\nchart\n  .selectAll(\"path.boundary\")\n  .data(boundaries)\n  .join(\"path\")\n  .attr(\"class\", \"boundary\")\n  .attr(\"transform\", `translate(${cx},${cy})`)\n  .attr(\"d\", (r) => boundaryArc.innerRadius(r - 1).outerRadius(r + 1)())\n  .attr(\"fill\", t.inkSoft)\n  .attr(\"fill-opacity\", 0.4);\n\n// --- Sector divider lines -----------------------------------------------------\nchart\n  .selectAll(\"line.divider\")\n  .data(sectors.flatMap((s) => [s.start, s.end]).filter((v, i, a) => a.indexOf(v) === i))\n  .join(\"line\")\n  .attr(\"class\", \"divider\")\n  .attr(\"x1\", (deg) => polar(deg, rInner)[0])\n  .attr(\"y1\", (deg) => polar(deg, rInner)[1])\n  .attr(\"x2\", (deg) => polar(deg, rOuter)[0])\n  .attr(\"y2\", (deg) => polar(deg, rOuter)[1])\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Ring-name labels directly on the rings, sitting on the flat diameter --\n// Every ring's items leave the -90deg edge clear except \"Now\" (whose AI & ML\n// item sits at -85.5deg); \"Now\" uses the +90deg edge instead, where the\n// nearest item (Biotech, ring \"Now\") is 40deg+ away. The other three rings\n// all keep 18deg+ of clearance at -90deg, so they share that side.\nchart\n  .selectAll(\"text.ring-label\")\n  .data(rings)\n  .join(\"text\")\n  .attr(\"class\", \"ring-label\")\n  .attr(\"x\", (d, i) => polar(i === 0 ? 90 : -90, (d.inner + d.outer) / 2)[0])\n  .attr(\"y\", (d, i) => polar(i === 0 ? 90 : -90, (d.inner + d.outer) / 2)[1])\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dy\", \"0.35em\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"12px\")\n  .style(\"font-weight\", \"600\")\n  .style(\"paint-order\", \"stroke\")\n  .style(\"stroke\", t.pageBg)\n  .style(\"stroke-width\", \"4px\")\n  .text((d) => d.name);\n\n// --- Sector header labels along the outer edge --------------------------------\nchart\n  .selectAll(\"text.sector-label\")\n  .data(sectors)\n  .join(\"text\")\n  .attr(\"class\", \"sector-label\")\n  .attr(\"x\", (d) => polar(d.mid, rOuter + 40)[0])\n  .attr(\"y\", (d) => polar(d.mid, rOuter + 40)[1])\n  .attr(\"text-anchor\", (d) => (d.mid <= -45 ? \"start\" : d.mid >= 45 ? \"end\" : \"middle\"))\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => d.name);\n\n// --- Item markers --------------------------------------------------------------\nconst symbolGen = d3.symbol().size(300);\nchart\n  .selectAll(\"path.marker\")\n  .data(items)\n  .join(\"path\")\n  .attr(\"class\", \"marker\")\n  .attr(\"transform\", (d) => `translate(${d.x},${d.y})`)\n  .attr(\"d\", (d) => symbolGen.type(sectors[d.sector].symbol)())\n  .attr(\"fill\", (d) => colorScale(sectors[d.sector].name))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2);\n\n// --- Item labels -----------------------------------------------------------\nconst itemLabels = chart\n  .selectAll(\"text.item-label\")\n  .data(items)\n  .join(\"text\")\n  .attr(\"class\", \"item-label\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .style(\"paint-order\", \"stroke\")\n  .style(\"stroke\", t.pageBg)\n  .style(\"stroke-width\", \"4px\")\n  .text((d) => d.name);\n\nconst applyLabelPos = () =>\n  itemLabels.each(function (d) {\n    const p = labelPos(d);\n    d3.select(this).attr(\"x\", p.x).attr(\"y\", p.y).attr(\"text-anchor\", p.anchor);\n  });\napplyLabelPos();\n\n// Collision avoidance: real getBBox() measurement (this runs in an actual\n// browser, not jsdom) beats guessing text widths from character counts. Any\n// label overlapping another label OR a neighboring item's marker gets nudged\n// outward and a few degrees sideways (capped below, never far enough to\n// cross a sector divider). Markers never move, so their boxes are fixed.\nconst boxesOverlap = (a, b, pad = 3) =>\n  a.x < b.x + b.width + pad &&\n  b.x < a.x + a.width + pad &&\n  a.y < b.y + b.height + pad &&\n  b.y < a.y + a.height + pad;\n// Build marker boxes from the known data coordinates rather than getBBox():\n// each <path> carries its position via a `transform=\"translate(...)\"`, and\n// getBBox() reports the LOCAL (pre-transform) box — i.e. centered on the\n// origin for every marker alike — so it can't be used for hit-testing here.\nconst markerHalf = 15; // symbol size 300 -> ~20px across, plus stroke/halo\nconst markerBoxes = items.map((d) => ({\n  x: d.x - markerHalf,\n  y: d.y - markerHalf,\n  width: markerHalf * 2,\n  height: markerHalf * 2,\n}));\n// Capped well under the ring spacing (~95px) so a label can clear a\n// same-ring neighbor but never leapfrogs into the next ring's territory.\n// The angular nudge is capped small (8°) so it can separate close same-ring\n// neighbors without drifting far enough to cross a sector divider.\nconst maxPush = 60;\nconst maxSpread = 8;\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));\n// `awayFromAngle` is the conflicting element's angle: d rotates away from it\n// specifically, so two items sharing a side of the sector's mid-line spread\n// apart from *each other* instead of both drifting toward the same edge.\nconst nudge = (d, awayFromAngle) => {\n  const canPush = d.labelPush < maxPush;\n  const canSpread = Math.abs(d.labelAngleAdj) < maxSpread;\n  if (!canPush && !canSpread) return false;\n  if (canPush) d.labelPush = Math.min(d.labelPush + 8, maxPush);\n  if (canSpread) {\n    const dir = Math.sign(d.angle - awayFromAngle) || (d.offset < 0 ? -1 : 1);\n    d.labelAngleAdj = clamp(d.labelAngleAdj + dir * 1.5, -maxSpread, maxSpread);\n  }\n  return true;\n};\nfor (let iter = 0; iter < 40; iter++) {\n  const boxes = itemLabels.nodes().map((n) => n.getBBox());\n  let moved = false;\n  for (let i = 0; i < boxes.length; i++) {\n    for (let j = i + 1; j < boxes.length; j++) {\n      if (boxesOverlap(boxes[i], boxes[j])) {\n        moved = nudge(items[j], items[i].angle) || moved;\n      }\n    }\n    for (let k = 0; k < markerBoxes.length; k++) {\n      if (k !== i && boxesOverlap(boxes[i], markerBoxes[k])) {\n        moved = nudge(items[i], items[k].angle) || moved;\n      }\n    }\n  }\n  if (!moved) break;\n  applyLabelPos();\n}\n\n// --- Title -----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"radar-innovation-timeline · javascript · d3 · anyplot.ai\");\n\n// --- Legend: sector color + marker shape key, centered under the dome ------\nconst legend = svg.append(\"g\");\nconst legendItems = legend\n  .selectAll(\"g.legend-item\")\n  .data(sectors)\n  .join(\"g\")\n  .attr(\"class\", \"legend-item\");\n\nlegendItems\n  .append(\"path\")\n  .attr(\"d\", (d) => d3.symbol().type(d.symbol).size(220)())\n  .attr(\"fill\", (d) => colorScale(d.name))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\n\nlegendItems\n  .append(\"text\")\n  .attr(\"x\", 16)\n  .attr(\"y\", 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text((d) => d.name);\n\n// Measure each item's rendered width (real browser layout) and center the row —\n// works for any row of <g> items, reused below for the time-horizon key.\nconst centerRow = (rowItems, y, gap) => {\n  const widths = rowItems.nodes().map((node) => node.getBBox().width);\n  const totalWidth = widths.reduce((sum, w) => sum + w, 0) + gap * (widths.length - 1);\n  let cursor = cx - totalWidth / 2;\n  rowItems.each(function (d, i) {\n    d3.select(this).attr(\"transform\", `translate(${cursor},${y})`);\n    cursor += widths[i] + gap;\n  });\n};\ncenterRow(legendItems, cy + 160, 44);\n\n// --- Time-horizon key: ring names, in near-to-far order --------------------\nconst ringKeyItems = svg\n  .append(\"g\")\n  .selectAll(\"g.ring-key-item\")\n  .data(rings)\n  .join(\"g\")\n  .attr(\"class\", \"ring-key-item\");\n\nringKeyItems\n  .append(\"text\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => d.name);\n\ncenterRow(ringKeyItems, cy + 110, 40);\n"}