Innovation Radar with Time-Horizon Rings — Apache ECharts

A radial chart that maps innovations, technologies, or trends onto concentric rings representing time horizons and angular sectors representing thematic categories. Inner rings represent near-term items (e.g., "Now", "Next 6 months") while outer rings represent longer-term or emerging trends (e.g., "2-5 years", "Future"). Each item is placed as a labeled point within its sector and ring, with distinct markers or colors encoding categories. Inspired by ThoughtWorks Technology Radar and similar strategic planning visualizations.

Innovation Radar with Time-Horizon Rings rendered with Apache ECharts

Renders

JavaScript source (Apache ECharts)

// anyplot.ai
// radar-innovation-timeline: Innovation Radar with Time-Horizon Rings
// Library: echarts 6.1.0 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-08-26

//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;
const size = window.ANYPLOT_SIZE;

// --- Deterministic PRNG (LCG) so radial jitter is reproducible --------------
let seed = 42;
function rand() {
  seed = (seed * 1103515245 + 12345) & 0x7fffffff;
  return seed / 0x7fffffff;
}

// --- Data: rings = adoption horizon (inner = now), sectors = theme ---------
const RINGS = ["Adopt", "Trial", "Assess", "Hold"];
const SECTORS = ["AI & ML", "Data Platforms", "Cloud Infrastructure", "Security"];
const SYMBOLS = ["circle", "diamond", "triangle", "roundRect"];

const ITEMS = [
  { name: "LLM Fine-Tuning", sector: "AI & ML", ring: "Adopt" },
  { name: "Vector Databases", sector: "AI & ML", ring: "Adopt" },
  { name: "RAG Pipelines", sector: "AI & ML", ring: "Trial" },
  { name: "AI Agents", sector: "AI & ML", ring: "Trial" },
  { name: "Multimodal Models", sector: "AI & ML", ring: "Assess" },
  { name: "Synthetic Data Gen", sector: "AI & ML", ring: "Hold" },
  { name: "Streaming ETL", sector: "Data Platforms", ring: "Adopt" },
  { name: "Data Mesh", sector: "Data Platforms", ring: "Trial" },
  { name: "Feature Stores", sector: "Data Platforms", ring: "Trial" },
  { name: "Lakehouse Design", sector: "Data Platforms", ring: "Assess" },
  { name: "Graph Databases", sector: "Data Platforms", ring: "Assess" },
  { name: "Data Contracts", sector: "Data Platforms", ring: "Hold" },
  { name: "Kubernetes Autoscaling", sector: "Cloud Infrastructure", ring: "Adopt" },
  { name: "Serverless Functions", sector: "Cloud Infrastructure", ring: "Adopt" },
  { name: "Service Mesh", sector: "Cloud Infrastructure", ring: "Trial" },
  { name: "Edge Computing", sector: "Cloud Infrastructure", ring: "Assess" },
  { name: "WASM Runtimes", sector: "Cloud Infrastructure", ring: "Hold" },
  { name: "Confidential Compute", sector: "Cloud Infrastructure", ring: "Hold" },
  { name: "Zero Trust Network", sector: "Security", ring: "Adopt" },
  { name: "SBOM Tooling", sector: "Security", ring: "Trial" },
  { name: "Passwordless Auth", sector: "Security", ring: "Trial" },
  { name: "Post-Quantum Crypto", sector: "Security", ring: "Assess" },
  { name: "AI Threat Detection", sector: "Security", ring: "Assess" },
  { name: "Homomorphic Encryption", sector: "Security", ring: "Hold" },
];

// A couple of items get a small visual accent (bigger halo-bordered marker +
// bold label) so the chart points toward a specific insight beyond the base
// ring/sector position encoding: the fastest-advancing item, and one facing
// a looming regulatory deadline.
const HIGHLIGHTS = new Set(["AI Agents", "Post-Quantum Crypto"]);

// --- Angular layout: three-quarter circle, the remaining 90° carries the ---
// --- legend + ring labels instead of a sector, per the spec's guidance -----
const SWEEP = 270;
const SECTOR_WIDTH = SWEEP / SECTORS.length;
const ringIndexOf = (ring) => RINGS.indexOf(ring);

const groups = new Map();
ITEMS.forEach((item) => {
  const key = `${item.sector}|${item.ring}`;
  if (!groups.has(key)) groups.set(key, []);
  groups.get(key).push(item);
});

ITEMS.forEach((item) => {
  const sectorIdx = SECTORS.indexOf(item.sector);
  const siblings = groups.get(`${item.sector}|${item.ring}`);
  const pos = siblings.indexOf(item);
  const n = siblings.length;
  const margin = SECTOR_WIDTH * 0.18;
  const usable = SECTOR_WIDTH - margin * 2;
  const spread = n === 1 ? usable / 2 : (pos / (n - 1)) * usable;
  item.angle = sectorIdx * SECTOR_WIDTH + margin + spread;
  const band = ringIndexOf(item.ring);
  item.radius = band + 0.22 + rand() * 0.56; // stay clear of ring boundaries
});

// --- Polar geometry, shared between the ECharts polar grid and the manual --
// --- pixel math used to place the sector/ring text labels ------------------
const CENTER_X = size.width * 0.5;
const CENTER_Y = size.height * 0.55;
const OUTER_R = Math.min(size.width, size.height) * 0.37;
const INNER_R = OUTER_R * 0.13;

function polarPoint(radiusValue, angleValue) {
  const std = ((90 - angleValue) * Math.PI) / 180;
  const r = INNER_R + (radiusValue / 4) * (OUTER_R - INNER_R);
  return [CENTER_X + r * Math.cos(std), CENTER_Y - r * Math.sin(std)];
}

// labels point outward from the ring/sector they belong to instead of all
// defaulting to one side, so they stay legible around the full sweep
function outwardLabelPosition(angleValue) {
  const std = ((90 - angleValue) * Math.PI) / 180;
  const dx = Math.cos(std);
  const dy = Math.sin(std);
  if (Math.abs(dx) > Math.abs(dy)) return dx >= 0 ? "right" : "left";
  return dy >= 0 ? "top" : "bottom";
}

// text-graphic labels near the canvas edge must anchor away from that edge
// (not centered on it) or long names clip past the frame
function edgeSafeAlign(angleValue) {
  const std = ((90 - angleValue) * Math.PI) / 180;
  const dx = Math.cos(std);
  const dy = Math.sin(std);
  return {
    align: dx > 0.25 ? "right" : dx < -0.25 ? "left" : "center",
    verticalAlign: dy > 0.25 ? "top" : dy < -0.25 ? "bottom" : "middle",
  };
}

// --- Subtle per-ring background, fading from the near-term core outward ----
const inkRgb = t.theme === "light" ? "26,26,23" : "240,239,232";
const ringFills = [0.16, 0.115, 0.07, 0.035].map((a) => `rgba(${inkRgb},${a})`);

// --- Sector header + ring labels, placed via graphic elements so they can --
// --- sit beyond the axis's own radius domain and read outward -------------
const sectorLabels = SECTORS.map((sector, i) => {
  const angle = i * SECTOR_WIDTH + SECTOR_WIDTH / 2;
  const [x, y] = polarPoint(4.35, angle);
  return {
    type: "text",
    left: x,
    top: y,
    style: {
      text: sector,
      fill: t.palette[i],
      fontSize: 17,
      fontWeight: 600,
      ...edgeSafeAlign(angle),
    },
  };
});

const ringLabels = RINGS.map((ring, i) => {
  const angle = 315; // mid-gap, the empty quarter
  const [x, y] = polarPoint(i + 0.5, angle);
  return {
    type: "text",
    left: x,
    top: y,
    style: {
      text: ring,
      fill: t.inkSoft,
      fontSize: 13,
      fontStyle: "italic",
      ...edgeSafeAlign(angle),
    },
  };
});

// --- One series per sector so color + legend map 1:1 to the category -------
const series = SECTORS.map((sector, i) => ({
  name: sector,
  type: "scatter",
  coordinateSystem: "polar",
  symbol: SYMBOLS[i % SYMBOLS.length],
  symbolSize: 24,
  itemStyle: { color: t.palette[i], borderColor: t.pageBg, borderWidth: 2 },
  label: {
    show: true,
    formatter: (p) => p.data.name,
    distance: 9,
    fontSize: 14,
    color: t.inkSoft,
  },
  labelLayout: { hideOverlap: true },
  data: ITEMS.filter((item) => item.sector === sector).map((item) => {
    const highlighted = HIGHLIGHTS.has(item.name);
    return {
      name: item.name,
      value: [item.radius, item.angle],
      symbolSize: highlighted ? 32 : 24,
      itemStyle: highlighted
        ? { borderWidth: 3, shadowBlur: 14, shadowColor: t.palette[i] }
        : undefined,
      label: {
        position: outwardLabelPosition(item.angle),
        fontWeight: highlighted ? 700 : 400,
        fontSize: highlighted ? 15 : 14,
      },
    };
  }),
}));

// --- Init --------------------------------------------------------------------
const chart = echarts.init(document.getElementById("container"));

// --- Option -----------------------------------------------------------------
chart.setOption({
  animation: false,
  color: t.palette,
  backgroundColor: "transparent",
  title: {
    text: "radar-innovation-timeline · javascript · echarts · anyplot.ai",
    left: "center",
    top: 24,
    textStyle: { color: t.ink, fontSize: 22, fontWeight: 500 },
  },
  legend: {
    data: SECTORS,
    orient: "horizontal",
    left: "center",
    top: 68,
    itemWidth: 16,
    itemHeight: 16,
    itemGap: 26,
    textStyle: { color: t.ink, fontSize: 15 },
  },
  tooltip: {
    trigger: "item",
    formatter: (p) => `${p.data.name}<br/>${p.seriesName} · ${RINGS[Math.floor(p.data.value[0])]}`,
  },
  polar: { center: [CENTER_X, CENTER_Y], radius: [INNER_R, OUTER_R] },
  angleAxis: {
    type: "value",
    min: 0,
    max: 360,
    startAngle: 90,
    clockwise: true,
    axisLine: { show: false },
    axisTick: { show: false },
    axisLabel: { show: false },
    splitLine: { show: false },
  },
  radiusAxis: {
    type: "value",
    min: 0,
    max: 4,
    interval: 1,
    axisLine: { show: false },
    axisTick: { show: false },
    axisLabel: { show: false },
    splitLine: { lineStyle: { color: t.grid } },
    splitArea: { show: true, areaStyle: { color: ringFills } },
  },
  graphic: [...sectorLabels, ...ringLabels],
  series,
});

Part of Innovation Radar with Time-Horizon Rings on anyplot.ai.

Other implementations