{"spec_id":"funnel-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// funnel-basic: Basic Funnel Chart\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-05\n\n//# anyplot-orientation: square\n\n// Only the core Highcharts bundle is loaded (no `funnel` module), so the\n// trapezoid stack is computed by hand and drawn with `chart.renderer.path()`\n// — the same hand-drawn technique treemap-basic / sunburst-basic / sankey-basic\n// use for series types the core bundle doesn't ship. Each segment gets a\n// native SVG `<title>` for hover (same trick as treemap-basic).\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: e-commerce checkout funnel (unique site sessions) ---------------\nconst STAGES = [\"Site Visitors\", \"Product Views\", \"Added to Cart\", \"Started Checkout\", \"Completed Purchase\"];\nconst VALUES = [48000, 26400, 11200, 6100, 3850];\n\n// Position 5 (matte red) is the deferred bad/error anchor — skip it here\n// since the narrowest stage is a successful purchase, not a failure, and\n// pick cyan instead so palette order never implies the funnel's happy\n// ending is \"bad\". See default-style-guide.md \"Semantic exception\".\nconst COLORS = [t.palette[0], t.palette[1], t.palette[2], t.palette[3], t.palette[5]];\n\nconst n = STAGES.length;\nconst pct = (v) => `${((v / VALUES[0]) * 100).toFixed(1)}%`;\nconst fmt = (v) => v.toLocaleString(\"en-US\");\n\n// --- Title (fontsize scaled off the 67-char baseline) -----------------------\nconst TITLE_TEXT = \"Checkout Conversion · funnel-basic · javascript · highcharts · anyplot.ai\";\nconst TITLE_FS = Math.max(Math.round(22 * Math.min(1, 67 / TITLE_TEXT.length)), 14);\n\n// --- Chart shell (no series/axes — the funnel is drawn by hand) ------------\nconst chart = Highcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    marginTop: 130,\n    marginBottom: 40,\n    marginLeft: 40,\n    marginRight: 40,\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE_TEXT,\n    style: { color: t.ink, fontSize: TITLE_FS + \"px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: `Simulated site sessions, ${fmt(VALUES[0])} visitors → ${fmt(VALUES[n - 1])} purchases (${pct(VALUES[n - 1])} overall)`,\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: { visible: false },\n  yAxis: { visible: false },\n  legend: { enabled: false },\n  plotOptions: { series: { animation: false } },\n  series: [],\n});\n\nconst PLOT_W = chart.plotWidth;\nconst PLOT_H = chart.plotHeight;\nconst GAP = 10;\n\n// First-pass layout: a rough block split just wide enough to hold the\n// funnel + label gutter. The label strings are variable-width, so this\n// guess is deliberately not centered on the canvas yet — the whole\n// composition (funnel + leader lines + labels) is drawn into group `g`\n// below, then re-centered on `g`'s *actual* rendered bounding box (real\n// text extents, not the abstract split) so left/right whitespace balances\n// symmetrically regardless of how wide the label strings turn out to be.\nconst BLOCK_W = PLOT_W * 0.86;\nconst blockLeft = chart.plotLeft + (PLOT_W - BLOCK_W) / 2;\nconst funnelZoneW = BLOCK_W * 0.5;\n\nconst centerX = blockLeft + funnelZoneW / 2;\nconst maxHalfWidth = (funnelZoneW / 2) * 0.9;\nconst labelX = blockLeft + funnelZoneW + 24;\n\nconst scale = maxHalfWidth / VALUES[0];\nconst halfWidths = VALUES.map((v) => v * scale);\nconst bandHeight = (PLOT_H - (n - 1) * GAP) / n;\n\n// Steepest stage-to-stage drop-off (largest relative loss from one stage\n// to the next, i.e. the transition with the lowest stage-over-stage\n// conversion rate), called out below with a native Highcharts renderer\n// \"callout\" label pointing at the transition.\nconst dropRates = VALUES.slice(0, -1).map((v, i) => 1 - VALUES[i + 1] / v);\nconst steepestIdx = dropRates.indexOf(Math.max(...dropRates));\nlet dropAnchor = null;\n\nfunction addTooltip(el, text) {\n  const titleEl = document.createElementNS(\"http://www.w3.org/2000/svg\", \"title\");\n  titleEl.textContent = text;\n  el.element.appendChild(titleEl);\n}\n\nfunction labelColorFor(bgHex) {\n  const c = parseInt(bgHex.slice(1), 16);\n  const r = (c >> 16) & 255;\n  const g = (c >> 8) & 255;\n  const b = c & 255;\n  const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n  return luma > 0.55 ? \"#1A1A17\" : \"#FFFDF6\";\n}\n\nconst g = chart.renderer.g(\"funnel\").add();\n\nSTAGES.forEach((stage, i) => {\n  const y0 = chart.plotTop + i * (bandHeight + GAP);\n  const y1 = y0 + bandHeight;\n  const topHalf = halfWidths[i];\n  // Last stage has no next value to taper into — render it as a flat-bottomed\n  // band (its own width top and bottom) rather than inventing a taper amount.\n  const bottomHalf = i < n - 1 ? halfWidths[i + 1] : halfWidths[i];\n\n  if (i === steepestIdx) {\n    dropAnchor = { x: centerX - bottomHalf, y: y1 };\n  }\n\n  const path = [\n    [\"M\", centerX - topHalf, y0],\n    [\"L\", centerX + topHalf, y0],\n    [\"L\", centerX + bottomHalf, y1],\n    [\"L\", centerX - bottomHalf, y1],\n    [\"Z\"],\n  ];\n\n  const segment = chart.renderer\n    .path(path)\n    .attr({ fill: COLORS[i], stroke: t.pageBg, \"stroke-width\": 2, zIndex: 2 })\n    .add(g);\n  addTooltip(segment, `${stage}\\n${fmt(VALUES[i])} (${pct(VALUES[i])} of visitors)`);\n\n  // In-segment value label when the band is wide enough to hold it legibly.\n  const midY = (y0 + y1) / 2;\n  const avgHalf = (topHalf + bottomHalf) / 2;\n  if (avgHalf > 55) {\n    chart.renderer\n      .text(fmt(VALUES[i]), centerX, midY + 6)\n      .attr({ align: \"center\", zIndex: 3 })\n      .css({ color: labelColorFor(COLORS[i]), fontSize: \"16px\", fontWeight: \"600\", pointerEvents: \"none\" })\n      .add(g);\n  }\n\n  // Leader line + stage label in the right-hand gutter.\n  const leaderStartX = centerX + avgHalf + 6;\n  chart.renderer\n    .path([\n      [\"M\", leaderStartX, midY],\n      [\"L\", labelX - 8, midY],\n    ])\n    .attr({ stroke: t.inkSoft, \"stroke-width\": 1, zIndex: 1 })\n    .add(g);\n\n  chart.renderer\n    .text(stage, labelX, midY - 4)\n    .attr({ align: \"left\", zIndex: 3 })\n    .css({ color: t.ink, fontSize: \"16px\", fontWeight: \"600\" })\n    .add(g);\n  chart.renderer\n    .text(`${fmt(VALUES[i])} · ${pct(VALUES[i])}`, labelX, midY + 16)\n    .attr({ align: \"left\", zIndex: 3 })\n    .css({ color: t.inkSoft, fontSize: \"14px\" })\n    .add(g);\n});\n\n// Call out the steepest stage-to-stage drop-off with a native Highcharts\n// SVGRenderer \"callout\" label (bordered, pointer anchored at the transition)\n// — a distinctive Highcharts component, not a plain SVG <text>.\nif (dropAnchor) {\n  const calloutText = `Steepest drop: −${(dropRates[steepestIdx] * 100).toFixed(1)}%`;\n  const calloutX = Math.max(chart.plotLeft + 4, dropAnchor.x - 210);\n  const calloutY = dropAnchor.y - 34;\n  chart.renderer\n    .label(calloutText, calloutX, calloutY, \"callout\", dropAnchor.x, dropAnchor.y)\n    .css({ color: t.ink, fontSize: \"13px\", fontWeight: \"600\" })\n    .attr({ fill: t.elevatedBg, stroke: t.amber, \"stroke-width\": 1.5, padding: 8, r: 5, zIndex: 4 })\n    .add(g);\n}\n\n// Re-center the whole composition (funnel + leader lines + labels +\n// callout) on its *actual* rendered bounding box — real text extents,\n// not the abstract BLOCK_W split above — so left/right whitespace\n// balances symmetrically instead of trailing off wherever the\n// variable-width label strings happen to end.\nconst bbox = g.getBBox(true);\nconst shift = chart.plotLeft + PLOT_W / 2 - (bbox.x + bbox.width / 2);\ng.attr({ translateX: shift });\n"}