{"spec_id":"radar-innovation-timeline","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// radar-innovation-timeline: Innovation Radar with Time-Horizon Rings\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-26\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst FONT = \"system-ui, -apple-system, 'Segoe UI', sans-serif\";\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Rings = time-to-impact horizon (inner = nearer term), sectors = theme.\nconst RINGS = [\"Now\", \"Near-Term\", \"Mid-Term\", \"Future\"];\nconst RING_BOUNDS = [0, 0.28, 0.54, 0.78, 1.0]; // radius fractions, inner -> outer\n\nconst SECTORS = [\"AI & ML\", \"Cloud & Infrastructure\", \"Cybersecurity\", \"Sustainability Tech\"];\nconst SECTOR_SHAPES = [\"circle\", \"triangle\", \"rectRot\", \"rect\"];\n\n// Most cells hold exactly one item, but a few (the ones with the roomiest\n// angular lanes — Near-Term/Mid-Term/Future, where ring radius is larger)\n// deliberately host 2-3 items to exercise the shared-cell crowding-avoidance\n// logic below (lane subdivision + radial stagger), matching the spec's Notes\n// on jittering/offsetting labels within a ring.\nconst ITEMS = [\n  { name: \"LLM Assistants\", sector: \"AI & ML\", ring: \"Now\" },\n  { name: \"Multimodal Models\", sector: \"AI & ML\", ring: \"Near-Term\" },\n  { name: \"AI Agents\", sector: \"AI & ML\", ring: \"Mid-Term\" },\n  { name: \"Neuromorphic Chips\", sector: \"AI & ML\", ring: \"Future\" },\n\n  { name: \"Kubernetes Ops\", sector: \"Cloud & Infrastructure\", ring: \"Now\" },\n  { name: \"Edge Computing\", sector: \"Cloud & Infrastructure\", ring: \"Near-Term\" },\n  { name: \"5G Network Slicing\", sector: \"Cloud & Infrastructure\", ring: \"Near-Term\" },\n  { name: \"WebAssembly Runtimes\", sector: \"Cloud & Infrastructure\", ring: \"Mid-Term\" },\n  { name: \"Quantum Cloud APIs\", sector: \"Cloud & Infrastructure\", ring: \"Future\" },\n\n  { name: \"Zero Trust Networks\", sector: \"Cybersecurity\", ring: \"Now\" },\n  { name: \"AI Threat Detection\", sector: \"Cybersecurity\", ring: \"Near-Term\" },\n  { name: \"Post-Quantum Crypto\", sector: \"Cybersecurity\", ring: \"Mid-Term\" },\n  { name: \"Confidential Computing\", sector: \"Cybersecurity\", ring: \"Mid-Term\" },\n  { name: \"Homomorphic Encryption\", sector: \"Cybersecurity\", ring: \"Future\" },\n\n  { name: \"Carbon Accounting Tools\", sector: \"Sustainability Tech\", ring: \"Now\" },\n  { name: \"Green Cloud Regions\", sector: \"Sustainability Tech\", ring: \"Near-Term\" },\n  { name: \"Solid-State Batteries\", sector: \"Sustainability Tech\", ring: \"Mid-Term\" },\n  { name: \"Direct Air Capture\", sector: \"Sustainability Tech\", ring: \"Future\" },\n  { name: \"Fusion Energy Pilots\", sector: \"Sustainability Tech\", ring: \"Future\" },\n  { name: \"Circular Electronics Recycling\", sector: \"Sustainability Tech\", ring: \"Future\" },\n];\n\n// --- Angular layout: three-quarter circle, 90deg gap at top for ring labels -\nconst SECTOR_START_DEG = -45; // sector band runs from here clockwise (screen y-down)\nconst SECTOR_SWEEP_DEG = 270;\nconst SECTOR_WIDTH_DEG = SECTOR_SWEEP_DEG / SECTORS.length;\nconst SECTOR_MARGIN_DEG = 7; // inset from each sector's angular edges\nconst RING_PAD_DEG = 1.3; // inset within each ring's angular lane\n\n// Each sector is further split into one angular lane per ring, so items from\n// different rings never fall on the same ray (which would stack their labels\n// radially). Lane width is weighted by 1/sqrt(ring radius): the inner rings\n// have far less circumference to work with, so they get a proportionally\n// wider angular share of the sector than the spacious outer rings.\nconst RING_MID = RINGS.map((_, i) => (RING_BOUNDS[i] + RING_BOUNDS[i + 1]) / 2);\nconst RING_WEIGHT = RING_MID.map((m) => 1 / Math.sqrt(m));\nconst RING_WEIGHT_SUM = RING_WEIGHT.reduce((a, b) => a + b, 0);\nconst RING_CUM = [0];\nRING_WEIGHT.forEach((w) => RING_CUM.push(RING_CUM[RING_CUM.length - 1] + w / RING_WEIGHT_SUM));\n\n// Cells that hold more than one item split their angular lane into equal\n// sub-slots (one per item) and additionally stagger those items' radii\n// slightly within the ring band. The combined diagonal jitter keeps\n// same-cell markers — and their labels — from colliding.\nconst CELL_COUNTS = new Map();\nITEMS.forEach((item) => {\n  const key = `${item.sector}|${item.ring}`;\n  CELL_COUNTS.set(key, (CELL_COUNTS.get(key) || 0) + 1);\n});\nconst CELL_SEEN = new Map();\nconst RADIAL_STAGGER = 0.035; // fraction of R, per step away from the cell's mean radius\n\nconst PLACED = ITEMS.map((item, i) => {\n  const sectorIdx = SECTORS.indexOf(item.sector);\n  const ringIdx = RINGS.indexOf(item.ring);\n  const sectorFrom = SECTOR_START_DEG + sectorIdx * SECTOR_WIDTH_DEG + SECTOR_MARGIN_DEG;\n  const sectorTo = SECTOR_START_DEG + (sectorIdx + 1) * SECTOR_WIDTH_DEG - SECTOR_MARGIN_DEG;\n  const sectorSpan = sectorTo - sectorFrom;\n  const laneFrom = sectorFrom + RING_CUM[ringIdx] * sectorSpan + RING_PAD_DEG;\n  const laneTo = sectorFrom + RING_CUM[ringIdx + 1] * sectorSpan - RING_PAD_DEG;\n\n  const cellKey = `${item.sector}|${item.ring}`;\n  const cellCount = CELL_COUNTS.get(cellKey);\n  const cellIdx = CELL_SEEN.get(cellKey) || 0;\n  CELL_SEEN.set(cellKey, cellIdx + 1);\n\n  const slotWidth = (laneTo - laneFrom) / cellCount;\n  const angleDeg = laneFrom + slotWidth * (cellIdx + 0.5);\n  const radialJitter = cellCount > 1 ? (cellIdx - (cellCount - 1) / 2) * RADIAL_STAGGER : 0;\n  const radiusFrac = (RING_BOUNDS[ringIdx] + RING_BOUNDS[ringIdx + 1]) / 2 + radialJitter;\n\n  return { ...item, sectorIdx, ringIdx, angleDeg, radiusFrac, cellIdx, cellCount, order: i };\n});\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Custom chrome + marks plugin --------------------------------------------\n// Chart.js has no built-in \"rings x sectors\" chart type; this plugin draws the\n// radar face natively on the chart's own canvas via the core Plugin API (no\n// external plugin package, no other charting library). The chart's real\n// datasets below only exist to drive the legend swatches.\nconst innovationRadar = {\n  id: \"innovationRadar\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const side = Math.min(chartArea.width, chartArea.height);\n    const cx = chartArea.left + chartArea.width / 2;\n    const cy = chartArea.top + chartArea.height / 2;\n    const R = side * 0.36;\n\n    const toRad = (deg) => (deg * Math.PI) / 180;\n    const at = (deg, r) => ({ x: cx + r * Math.cos(toRad(deg)), y: cy + r * Math.sin(toRad(deg)) });\n\n    // Ring fills (subtle alternating bands)\n    ctx.save();\n    for (let i = RING_BOUNDS.length - 1; i > 0; i--) {\n      if (i % 2 !== 0) continue;\n      const rOuter = R * RING_BOUNDS[i];\n      const rInner = R * RING_BOUNDS[i - 1];\n      ctx.beginPath();\n      ctx.arc(cx, cy, rOuter, 0, Math.PI * 2);\n      ctx.arc(cx, cy, rInner, 0, Math.PI * 2, true);\n      ctx.fillStyle = t.grid;\n      ctx.globalAlpha = 0.6;\n      ctx.fill(\"evenodd\");\n    }\n    ctx.restore();\n\n    // Ring boundary circles\n    ctx.save();\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1.5;\n    for (let i = 0; i < RING_BOUNDS.length; i++) {\n      ctx.beginPath();\n      ctx.arc(cx, cy, R * RING_BOUNDS[i] || 0.001, 0, Math.PI * 2);\n      ctx.stroke();\n    }\n    ctx.restore();\n\n    // Sector divider spokes\n    ctx.save();\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1.5;\n    for (let k = 0; k <= SECTORS.length; k++) {\n      const deg = SECTOR_START_DEG + k * SECTOR_WIDTH_DEG;\n      const outer = at(deg, R);\n      ctx.beginPath();\n      ctx.moveTo(cx, cy);\n      ctx.lineTo(outer.x, outer.y);\n      ctx.stroke();\n    }\n    ctx.restore();\n\n    // Sector header labels along the outer edge\n    ctx.save();\n    ctx.font = `600 15px ${FONT}`;\n    ctx.textBaseline = \"middle\";\n    SECTORS.forEach((name, idx) => {\n      const mid = SECTOR_START_DEG + (idx + 0.5) * SECTOR_WIDTH_DEG;\n      const pos = at(mid, R * 1.1);\n      const dx = Math.cos(toRad(mid));\n      const dy = Math.sin(toRad(mid));\n      ctx.textAlign = dx > 0.3 ? \"left\" : dx < -0.3 ? \"right\" : \"center\";\n      ctx.textBaseline = dy > 0.3 ? \"top\" : dy < -0.3 ? \"bottom\" : \"middle\";\n      ctx.fillStyle = t.palette[idx % t.palette.length];\n      ctx.fillText(name, pos.x, pos.y);\n    });\n    ctx.restore();\n\n    // Ring labels, stacked in the top gap\n    ctx.save();\n    ctx.font = `500 13px ${FONT}`;\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    RINGS.forEach((name, idx) => {\n      const r = (R * (RING_BOUNDS[idx] + RING_BOUNDS[idx + 1])) / 2;\n      const pos = at(-90, r);\n      ctx.fillText(name, pos.x, pos.y);\n    });\n    ctx.restore();\n\n    // Item markers + labels\n    PLACED.forEach((d) => {\n      const p = at(d.angleDeg, R * d.radiusFrac);\n      const color = t.palette[d.sectorIdx % t.palette.length];\n      const mr = 9;\n\n      ctx.save();\n      ctx.fillStyle = color;\n      ctx.strokeStyle = t.pageBg;\n      ctx.lineWidth = 1.5;\n      ctx.beginPath();\n      const shape = SECTOR_SHAPES[d.sectorIdx];\n      if (shape === \"circle\") {\n        ctx.arc(p.x, p.y, mr, 0, Math.PI * 2);\n      } else if (shape === \"triangle\") {\n        [-90, 150, 30].forEach((a, i) => {\n          const rad = toRad(a);\n          const vx = p.x + mr * 1.2 * Math.cos(rad);\n          const vy = p.y + mr * 1.2 * Math.sin(rad);\n          if (i === 0) ctx.moveTo(vx, vy);\n          else ctx.lineTo(vx, vy);\n        });\n        ctx.closePath();\n      } else if (shape === \"rectRot\") {\n        [0, 90, 180, 270].forEach((a, i) => {\n          const rad = toRad(a);\n          const vx = p.x + mr * 1.15 * Math.cos(rad);\n          const vy = p.y + mr * 1.15 * Math.sin(rad);\n          if (i === 0) ctx.moveTo(vx, vy);\n          else ctx.lineTo(vx, vy);\n        });\n        ctx.closePath();\n      } else {\n        const s = mr * 1.6;\n        ctx.rect(p.x - s / 2, p.y - s / 2, s, s);\n      }\n      ctx.fill();\n      ctx.stroke();\n      ctx.restore();\n\n      // Label: short diagonal offset, decoupled from the radial direction so\n      // labels from neighbouring rings on near-horizontal sectors don't stack.\n      // The horizontal gap clears the marker itself so the glyph never sits\n      // on top of its own label. Same-cell neighbours (cellCount > 1) already\n      // sit spread along the ring's tangent (the lane-subdivision direction);\n      // pushing each label further along that *same* signed tangent vector\n      // (rather than a fixed screen-axis guess) always reinforces that spread\n      // instead of risking cancelling it out on sectors where the tangent\n      // points the \"wrong\" way on screen.\n      const dx = Math.cos(toRad(d.angleDeg));\n      const labelGap = mr + 9;\n      let lx = p.x + (dx >= 0 ? labelGap : -labelGap);\n      let ly = p.y + (d.order % 2 === 0 ? -11 : 11);\n      if (d.cellCount > 1) {\n        const stackRank = d.cellIdx - (d.cellCount - 1) / 2;\n        const rad = toRad(d.angleDeg);\n        const tangentX = -Math.sin(rad);\n        const tangentY = Math.cos(rad);\n        const push = stackRank * 20;\n        lx = p.x + (dx >= 0 ? labelGap : -labelGap) + tangentX * push;\n        ly = p.y + tangentY * push;\n      }\n      ctx.save();\n      ctx.font = `12px ${FONT}`;\n      ctx.fillStyle = t.ink;\n      ctx.textAlign = dx >= 0 ? \"left\" : \"right\";\n      ctx.textBaseline = \"middle\";\n      ctx.fillText(d.name, lx, ly);\n      ctx.restore();\n    });\n  },\n};\n\n// --- Chart ---------------------------------------------------------------\n// Real datasets carry no visible marks (pointRadius 0) — they exist only to\n// drive the legend swatches; the radar face itself is drawn by the plugin.\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: SECTORS.map((name, idx) => ({\n      label: name,\n      data: [{ x: 0, y: 0 }],\n      backgroundColor: t.palette[idx % t.palette.length],\n      borderColor: t.palette[idx % t.palette.length],\n      pointStyle: SECTOR_SHAPES[idx],\n      pointRadius: 0,\n      showLine: false,\n    })),\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 10, right: 20, bottom: 10, left: 20 } },\n    scales: {\n      x: { min: -1.4, max: 1.4, display: false, grid: { display: false } },\n      y: { min: -1.4, max: 1.4, display: false, grid: { display: false } },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"radar-innovation-timeline · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n        padding: { bottom: 16 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: { color: t.ink, font: { size: 16 }, usePointStyle: true, pointStyleWidth: 14 },\n      },\n      tooltip: { enabled: false },\n    },\n  },\n  plugins: [innovationRadar],\n});\n"}