{"spec_id":"bar-spine","library":"echarts","language":"javascript","code":"// anyplot.ai\n// bar-spine: Spine Plot for Two-Variable Proportions\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 95/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Customer counts by subscription tier and churn status. Bar width encodes\n// each tier's share of the total customer base; segment height encodes the\n// churn-status share within that tier.\nconst tiers = [\n  { name: \"Free\", counts: { Churned: 320, \"At Risk\": 200, Retained: 280 } },\n  { name: \"Basic\", counts: { Churned: 100, \"At Risk\": 150, Retained: 250 } },\n  { name: \"Premium\", counts: { Churned: 30, \"At Risk\": 60, Retained: 210 } },\n  { name: \"Enterprise\", counts: { Churned: 8, \"At Risk\": 22, Retained: 120 } },\n];\nconst statusOrder = [\"Churned\", \"At Risk\", \"Retained\"];\nconst statusColor = { Churned: t.palette[4], \"At Risk\": t.amber, Retained: t.palette[0] };\n// Fixed (non-theme-adaptive) label colors chosen for contrast against each\n// status's own fixed data fill — the fills never change between themes, so\n// the label colors must not either (t.ink/t.pageBg flip per theme and would\n// invert the contrast in dark mode).\nconst LABEL_LIGHT = \"#FAF8F1\";\nconst LABEL_DARK = \"#1A1A17\";\nconst statusTextColor = { Churned: LABEL_LIGHT, \"At Risk\": LABEL_DARK, Retained: LABEL_LIGHT };\n\nconst totals = tiers.map((tier) => statusOrder.reduce((sum, status) => sum + tier.counts[status], 0));\nconst grandTotal = totals.reduce((sum, total) => sum + total, 0);\n\nlet cursorX = 0;\nconst tierMeta = [];\nconst segments = [];\ntiers.forEach((tier, i) => {\n  const total = totals[i];\n  const width = (total / grandTotal) * 100;\n  const x0 = cursorX;\n  const x1 = cursorX + width;\n  cursorX = x1;\n  tierMeta.push({ name: tier.name, x0, x1 });\n\n  let cursorY = 0;\n  statusOrder.forEach((status) => {\n    const count = tier.counts[status];\n    const pct = (count / total) * 100;\n    const y0 = cursorY;\n    const y1 = cursorY + pct;\n    cursorY = y1;\n    segments.push({ x0, x1, y0, y1, count, pct, tier: tier.name, status });\n  });\n});\n\n// --- Custom-series renderer for variable-width stacked segments -------------\nfunction segmentRenderItem(params, api) {\n  const seg = segments[params.dataIndex];\n  const p1 = api.coord([seg.x0, seg.y0]);\n  const p2 = api.coord([seg.x1, seg.y1]);\n  const rectShape = echarts.graphic.clipRectByRect(\n    { x: p1[0], y: p2[1], width: p2[0] - p1[0], height: p1[1] - p2[1] },\n    { x: params.coordSys.x, y: params.coordSys.y, width: params.coordSys.width, height: params.coordSys.height }\n  );\n  if (!rectShape) return;\n  const children = [\n    { type: \"rect\", shape: rectShape, style: { fill: statusColor[seg.status], stroke: t.pageBg, lineWidth: 2 } },\n  ];\n  if (rectShape.width > 70 && rectShape.height > 40) {\n    children.push({\n      type: \"text\",\n      x: rectShape.x + rectShape.width / 2,\n      y: rectShape.y + rectShape.height / 2,\n      style: {\n        text: `${Math.round(seg.pct)}%`,\n        fill: statusTextColor[seg.status],\n        fontSize: 14,\n        fontWeight: 500,\n        align: \"center\",\n        verticalAlign: \"middle\",\n      },\n    });\n  }\n  return { type: \"group\", children };\n}\n\n// --- Custom-series renderer for centered tier labels below the axis --------\nfunction tierLabelRenderItem(params, api) {\n  const meta = tierMeta[params.dataIndex];\n  const p = api.coord([(meta.x0 + meta.x1) / 2, 0]);\n  return {\n    type: \"text\",\n    x: p[0],\n    y: p[1] + 30,\n    style: { text: meta.name, fill: t.inkSoft, fontSize: 14, align: \"center\", verticalAlign: \"middle\" },\n  };\n}\n\n// --- Custom-series renderer for a manually laid out legend row -------------\n// ECharts' built-in legend only syncs to series names; since the segments\n// live in one \"custom\" series, the legend swatches are drawn directly instead.\nconst legendY = 96;\nconst legendItems = statusOrder.map((status) => ({ status, width: 16 + 8 + status.length * 8 }));\nconst legendTotalWidth = legendItems.reduce((sum, item) => sum + item.width, 0) + 28 * (legendItems.length - 1);\nlet legendCursorX = (window.ANYPLOT_SIZE.width - legendTotalWidth) / 2;\nlegendItems.forEach((item) => {\n  item.x0 = legendCursorX;\n  legendCursorX += item.width + 28;\n});\n\nfunction legendRenderItem(params, api) {\n  const item = legendItems[params.dataIndex];\n  return {\n    type: \"group\",\n    children: [\n      {\n        type: \"rect\",\n        shape: { x: item.x0, y: legendY, width: 16, height: 16 },\n        style: { fill: statusColor[item.status] },\n      },\n      {\n        type: \"text\",\n        x: item.x0 + 24,\n        y: legendY + 8,\n        style: { text: item.status, fill: t.inkSoft, fontSize: 14, verticalAlign: \"middle\" },\n      },\n    ],\n  };\n}\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option ---------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"bar-spine · javascript · echarts · anyplot.ai\",\n    subtext: \"Bar width = tier size, segment height = churn-status share within tier\",\n    left: \"center\",\n    top: 24,\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: 500 },\n    subtextStyle: { color: t.inkSoft, fontSize: 14 },\n  },\n  grid: { left: 90, right: 50, top: 150, bottom: 90 },\n  xAxis: { type: \"value\", min: 0, max: 100, show: false },\n  yAxis: {\n    type: \"value\",\n    min: 0,\n    max: 100,\n    name: \"Share within tier (%)\",\n    nameLocation: \"center\",\n    nameGap: 55,\n    nameTextStyle: { color: t.ink, fontSize: 16 },\n    axisLabel: { color: t.inkSoft, fontSize: 14, formatter: \"{value}%\" },\n    axisLine: { show: false },\n    axisTick: { show: false },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n  tooltip: {\n    trigger: \"item\",\n    formatter: (params) => {\n      if (params.seriesName !== \"segments\") return \"\";\n      const seg = segments[params.dataIndex];\n      return `${seg.tier} — ${seg.status}<br/>${seg.count} customers (${seg.pct.toFixed(1)}%)`;\n    },\n  },\n  series: [\n    {\n      type: \"custom\",\n      name: \"segments\",\n      renderItem: segmentRenderItem,\n      data: segments.map((seg) => seg.count),\n      z: 2,\n    },\n    {\n      type: \"custom\",\n      name: \"__tier_labels\",\n      silent: true,\n      renderItem: tierLabelRenderItem,\n      data: tierMeta.map(() => 0),\n      z: 3,\n    },\n    {\n      type: \"custom\",\n      name: \"__legend\",\n      silent: true,\n      renderItem: legendRenderItem,\n      data: legendItems.map(() => 0),\n      z: 3,\n    },\n  ],\n});\n"}