{"spec_id":"bar-spine","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// bar-spine: Spine Plot for Two-Variable Proportions\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// SaaS subscription tiers: bar width = marginal customer count per tier,\n// stacked segment heights = conditional retained/churned split within tier.\nconst TIERS = [\n  { name: \"Free\", retained: 2800, churned: 1200 },\n  { name: \"Basic\", retained: 2000, churned: 500 },\n  { name: \"Pro\", retained: 1080, churned: 120 },\n  { name: \"Enterprise\", retained: 288, churned: 12 },\n];\n\n// Highcharts' core bundle has no variable-width column series (that lives in\n// the variwide module, which isn't loaded). A spine plot is built instead as\n// a stacked, fully-opaque step-area chart on a linear x-axis: each tier gets\n// two x-positions (its cumulative-width start/end) holding the same y value,\n// so the area is flat across the tier's width and steps to the next tier's\n// height at the boundary. A tiny epsilon keeps boundary x-values strictly\n// increasing (exact duplicates would double-count in Highcharts' per-x\n// stacking sum) while staying far below one screen pixel.\nconst EPS = 1;\nlet cursor = 0;\nconst segments = TIERS.map((tier, i) => {\n  const total = tier.retained + tier.churned;\n  const x0 = cursor;\n  const x1 = cursor + total;\n  cursor = x1;\n  return {\n    name: tier.name,\n    x0,\n    x1,\n    center: (x0 + x1) / 2,\n    startX: i === 0 ? x0 : x0 + EPS,\n    endX: i === TIERS.length - 1 ? x1 : x1 - EPS,\n    retainedPct: (tier.retained / total) * 100,\n    churnedPct: (tier.churned / total) * 100,\n  };\n});\nconst totalWidth = cursor;\n\nfunction findSegment(x) {\n  return (\n    segments.find((s) => x >= s.x0 - EPS && x <= s.x1 + EPS) ||\n    segments[segments.length - 1]\n  );\n}\n\n// Percentage data labels are a secondary, non-color cue for telling the\n// Retained/Churned bands apart (mitigates red-green CVD ambiguity) and\n// satisfy the spec's \"consider adding percentage labels when space permits\"\n// note. Only the segment's center point (inserted below, between its flat\n// startX/endX run) carries a label, and only when the band's own height\n// leaves enough room to render one legibly.\nconst MIN_LABEL_SHARE = 8;\nconst labelCenterXs = new Set(segments.map((s) => s.center));\nfunction segmentLabelFormatter() {\n  if (!labelCenterXs.has(this.x) || this.y < MIN_LABEL_SHARE) return null;\n  return `${Math.round(this.y)}%`;\n}\nconst labelStyle = {\n  color: \"#FFFFFF\",\n  fontSize: \"13px\",\n  fontWeight: \"600\",\n  textOutline: \"1px rgba(26, 26, 23, 0.55)\",\n};\n\n// Retained (good) keeps the brand-green first slot; churned (bad) takes the\n// semantic-red anchor rather than the next ordinal palette position.\nconst series = [\n  {\n    name: \"Retained\",\n    color: t.palette[0],\n    data: segments.flatMap((s) => [\n      { x: s.startX, y: s.retainedPct },\n      { x: s.center, y: s.retainedPct },\n      { x: s.endX, y: s.retainedPct },\n    ]),\n  },\n  {\n    name: \"Churned\",\n    color: t.palette[4],\n    data: segments.flatMap((s) => [\n      { x: s.startX, y: s.churnedPct },\n      { x: s.center, y: s.churnedPct },\n      { x: s.endX, y: s.churnedPct },\n    ]),\n  },\n];\n\n// --- Chart -------------------------------------------------------------------\nconst titleText =\n  \"SaaS Subscription Churn by Tier · bar-spine · javascript · highcharts · anyplot.ai\";\nconst titleRatio = titleText.length > 67 ? 67 / titleText.length : 1;\nconst titleFontSize = Math.max(14, Math.round(22 * titleRatio));\n\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"area\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: titleText,\n    style: { color: t.ink, fontSize: `${titleFontSize}px`, fontWeight: \"600\" },\n  },\n  xAxis: {\n    min: 0,\n    max: totalWidth,\n    title: {\n      text: \"Subscription tier — width ∝ customer count\",\n      style: { color: t.inkSoft, fontSize: \"16px\" },\n    },\n    tickPositions: segments.map((s) => s.center),\n    gridLineWidth: 0,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    tickLength: 6,\n    labels: {\n      formatter: function () {\n        return findSegment(this.value).name;\n      },\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n    },\n  },\n  yAxis: {\n    min: 0,\n    max: 100,\n    tickInterval: 20,\n    title: {\n      text: \"Share of tier (%)\",\n      style: { color: t.inkSoft, fontSize: \"16px\" },\n    },\n    gridLineColor: t.grid,\n    labels: {\n      format: \"{value}%\",\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n    },\n  },\n  legend: {\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n    symbolRadius: 2,\n    itemDistance: 24,\n    margin: 20,\n  },\n  tooltip: {\n    shared: true,\n    valueDecimals: 1,\n    valueSuffix: \"%\",\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    style: { color: t.ink, fontSize: \"13px\" },\n    formatter: function () {\n      const seg = findSegment(this.x);\n      const lines = [`<b>${seg.name}</b>`];\n      this.points.forEach((p) => {\n        lines.push(`${p.series.name}: ${p.y.toFixed(1)}%`);\n      });\n      return lines.join(\"<br/>\");\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    area: {\n      stacking: \"normal\",\n      fillOpacity: 1,\n      lineWidth: 1,\n      lineColor: t.pageBg,\n      marker: { enabled: false },\n      states: { hover: { enabled: false } },\n      dataLabels: {\n        enabled: true,\n        formatter: segmentLabelFormatter,\n        style: labelStyle,\n      },\n    },\n  },\n  series,\n});\n"}