{"spec_id":"marimekko-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// marimekko-basic: Basic Marimekko Chart\n// Library: chartjs 4.4.7 | JavaScript 22.23.1\n// Quality: 89/100 | Created: 2026-07-24\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Revenue ($M) by region (drives bar width) and product line (drives the\n// stacked share within each bar). Product mix is deliberately distinct per\n// region so the composition story (not just the width story) reads clearly:\n// North America and Asia Pacific are hardware-led, Europe is software-led,\n// and Latin America is services-led.\nconst regions = [\"North America\", \"Europe\", \"Asia Pacific\", \"Latin America\"];\nconst products = [\"Hardware\", \"Software\", \"Services\"];\nconst revenue = [\n  [210, 190, 100], // North America - hardware-led, largest overall\n  [60, 190, 50], // Europe - software-led\n  [100, 70, 60], // Asia Pacific - hardware-led, more balanced\n  [15, 25, 60], // Latin America - services-led, smallest overall\n];\nconst regionTotals = revenue.map((row) => row.reduce((sum, v) => sum + v, 0));\nconst grandTotal = regionTotals.reduce((sum, v) => sum + v, 0);\nconst topProductIndex = revenue.map((row) => row.indexOf(Math.max(...row)));\n\n// --- Layout: a fine category grid stands in for variable bar widths --------\n// Chart.js sizes every bar in a dataset uniformly, so each region becomes a\n// run of adjacent zero-gap grid slots proportional to its revenue share; a\n// single blank slot between runs reads as the gap between mekko columns.\nconst GRID_UNITS = 100;\nconst columnWidths = regionTotals.map((total) => Math.round((total / grandTotal) * GRID_UNITS));\ncolumnWidths[columnWidths.length - 1] += GRID_UNITS - columnWidths.reduce((a, b) => a + b, 0);\n\nconst labels = [];\nconst slotRegion = []; // region index per grid slot, -1 for a gap slot\nconst centerSlot = []; // the grid slot index used to anchor each region's label\nregions.forEach((region, ri) => {\n  const start = labels.length;\n  for (let i = 0; i < columnWidths[ri]; i++) {\n    labels.push(\"\");\n    slotRegion.push(ri);\n  }\n  const center = start + Math.floor(columnWidths[ri] / 2);\n  labels[center] = region;\n  centerSlot.push(center);\n  if (ri < regions.length - 1) {\n    labels.push(\"\");\n    slotRegion.push(-1);\n  }\n});\n\n// --- Storytelling: callout the largest segment per region -------------------\n// Relative luminance (WCAG) decides whether the callout text reads better in\n// near-black ink or near-cream, so it stays legible on every palette color.\nfunction relativeLuminance(hex) {\n  const [r, g, b] = hex\n    .slice(1)\n    .match(/../g)\n    .map((c) => parseInt(c, 16) / 255)\n    .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\nconst calloutPlugin = {\n  id: \"segmentCallouts\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    ctx.save();\n    ctx.font = \"bold 15px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    regions.forEach((region, ri) => {\n      const pi = topProductIndex[ri];\n      const bar = chart.getDatasetMeta(pi).data[centerSlot[ri]];\n      if (!bar) return;\n      const share = (revenue[ri][pi] / regionTotals[ri]) * 100;\n      const full = `$${revenue[ri][pi]}M · ${share.toFixed(0)}%`;\n      const short = `${share.toFixed(0)}%`;\n      const segmentWidth = bar.width * columnWidths[ri];\n      const text = ctx.measureText(full).width <= segmentWidth - 8 ? full : short;\n      if (ctx.measureText(text).width > segmentWidth - 8) return;\n      const fill = t.palette[pi % t.palette.length];\n      ctx.fillStyle = relativeLuminance(fill) > 0.55 ? \"#1A1A17\" : \"#FAF8F1\";\n      ctx.fillText(text, bar.x, (bar.y + bar.base) / 2);\n    });\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"bar\",\n  plugins: [calloutPlugin],\n  data: {\n    labels,\n    datasets: products.map((product, pi) => ({\n      label: product,\n      data: slotRegion.map((ri) => (ri === -1 ? 0 : (revenue[ri][pi] / regionTotals[ri]) * 100)),\n      backgroundColor: t.palette[pi % t.palette.length],\n      borderWidth: 0,\n      barPercentage: 1,\n      categoryPercentage: 1,\n    })),\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"marimekko-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: {\n        labels: { color: t.ink, font: { size: 16 } },\n      },\n      tooltip: {\n        filter: (item) => item.parsed.y > 0,\n        callbacks: {\n          title: (items) => regions[slotRegion[items[0].dataIndex]],\n          label: (item) => {\n            const ri = slotRegion[item.dataIndex];\n            const value = revenue[ri][item.datasetIndex];\n            return `${item.dataset.label}: $${value}M (${item.parsed.y.toFixed(0)}%)`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        stacked: true,\n        ticks: { color: t.inkSoft, font: { size: 14 }, autoSkip: false, maxRotation: 0 },\n        grid: { display: false },\n        title: {\n          display: true,\n          text: \"Region — bar width ∝ total revenue\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n      y: {\n        stacked: true,\n        min: 0,\n        max: 100,\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          stepSize: 25,\n          callback: (value) => `${value}%`,\n        },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Share of Region Revenue\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n});\n"}