{"spec_id":"scatter-matrix","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// scatter-matrix: Scatter Plot Matrix\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-09\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic LCG) ------------------------------------\nfunction makeLcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\nfunction randNormal(mean, std) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\n// Iris-like flower measurements across three species — real cross-variable\n// correlation emerges both from between-species clustering and from a\n// petal-length -> petal-width relationship within each species.\nconst POINTS_PER_SPECIES = 50;\nconst speciesSpecs = [\n  {\n    name: \"Setosa\",\n    color: t.palette[0],\n    sepalLength: { mean: 5.0, std: 0.35 },\n    sepalWidth: { mean: 3.4, std: 0.38 },\n    petalLength: { mean: 1.46, std: 0.17 },\n    petalWidthRatio: 0.17,\n    petalWidthNoiseStd: 0.05,\n  },\n  {\n    name: \"Versicolor\",\n    color: t.palette[1],\n    sepalLength: { mean: 5.94, std: 0.51 },\n    sepalWidth: { mean: 2.77, std: 0.31 },\n    petalLength: { mean: 4.26, std: 0.47 },\n    petalWidthRatio: 0.31,\n    petalWidthNoiseStd: 0.12,\n  },\n  {\n    name: \"Virginica\",\n    color: t.palette[2],\n    sepalLength: { mean: 6.59, std: 0.64 },\n    sepalWidth: { mean: 2.97, std: 0.32 },\n    petalLength: { mean: 5.55, std: 0.55 },\n    petalWidthRatio: 0.37,\n    petalWidthNoiseStd: 0.13,\n  },\n];\n\nconst records = [];\nfor (const spec of speciesSpecs) {\n  for (let i = 0; i < POINTS_PER_SPECIES; i++) {\n    const petalLength = Math.max(\n      0.1,\n      randNormal(spec.petalLength.mean, spec.petalLength.std),\n    );\n    const petalWidth = Math.max(\n      0.05,\n      petalLength * spec.petalWidthRatio +\n        randNormal(0, spec.petalWidthNoiseStd),\n    );\n    records.push({\n      species: spec.name,\n      color: spec.color,\n      sepal_length: Math.max(0.1, randNormal(spec.sepalLength.mean, spec.sepalLength.std)),\n      sepal_width: Math.max(0.1, randNormal(spec.sepalWidth.mean, spec.sepalWidth.std)),\n      petal_length: petalLength,\n      petal_width: petalWidth,\n    });\n  }\n}\n\nconst variables = [\n  { key: \"sepal_length\", label: \"Sepal Length (cm)\" },\n  { key: \"sepal_width\", label: \"Sepal Width (cm)\" },\n  { key: \"petal_length\", label: \"Petal Length (cm)\" },\n  { key: \"petal_width\", label: \"Petal Width (cm)\" },\n];\nconst N = variables.length;\nconst NUM_BINS = 10;\n\n// Shared per-variable domain — used as X range whenever the variable sits in\n// a column, and as Y range whenever it sits in a row, so the matrix reads\n// consistently down columns and across rows.\nfunction getDomain(values) {\n  const min = Math.min(...values);\n  const max = Math.max(...values);\n  const pad = (max - min) * 0.08 || 1;\n  return { min: min - pad, max: max + pad };\n}\nconst domainByKey = {};\nfor (const v of variables) {\n  domainByKey[v.key] = getDomain(records.map((r) => r[v.key]));\n}\n\n// Chart.js-specific technique: a registered plugin (beforeDraw hook) that\n// paints an accent stroke directly onto a chart's chartArea. Applied only to\n// the petal-length/petal-width cells — the strongest pairwise relationship in\n// the dataset — so the plugin system itself carries the focal-insight cue\n// rather than a CSS wrapper.\nconst FOCUS_PAIR = new Set([\"petal_length\", \"petal_width\"]);\nconst focusAccentPlugin = {\n  id: \"focusAccent\",\n  beforeDraw(chart, _args, opts) {\n    if (!opts?.active) return;\n    const { ctx, chartArea } = chart;\n    if (!chartArea) return;\n    ctx.save();\n    ctx.strokeStyle = opts.color;\n    ctx.lineWidth = 2;\n    ctx.strokeRect(\n      chartArea.left + 1,\n      chartArea.top + 1,\n      chartArea.right - chartArea.left - 2,\n      chartArea.bottom - chartArea.top - 2,\n    );\n    ctx.restore();\n  },\n};\nChart.register(focusAccentPlugin);\n\n// Per-variable stacked histogram counts (diagonal cells)\nfunction histogramBySpecies(key) {\n  const { min, max } = domainByKey[key];\n  const binWidth = (max - min) / NUM_BINS;\n  const counts = {};\n  for (const spec of speciesSpecs) counts[spec.name] = new Array(NUM_BINS).fill(0);\n  for (const r of records) {\n    const idx = Math.min(\n      NUM_BINS - 1,\n      Math.max(0, Math.floor((r[key] - min) / binWidth)),\n    );\n    counts[r.species][idx] += 1;\n  }\n  const binLabels = Array.from({ length: NUM_BINS }, (_, i) =>\n    (min + binWidth * (i + 0.5)).toFixed(1),\n  );\n  return { binLabels, counts };\n}\n\n// --- Scaffolding -------------------------------------------------------------\nconst style = document.createElement(\"style\");\nstyle.textContent = \"#container, #container * { box-sizing: border-box; }\";\ndocument.head.appendChild(style);\n\nconst container = document.getElementById(\"container\");\ncontainer.style.display = \"flex\";\ncontainer.style.flexDirection = \"column\";\ncontainer.style.padding = \"22px 26px 16px 20px\";\ncontainer.style.background = t.pageBg;\ncontainer.style.fontFamily =\n  \"system-ui, -apple-system, Helvetica, Arial, sans-serif\";\n\nconst title = document.createElement(\"div\");\ntitle.textContent = \"scatter-matrix · javascript · chartjs · anyplot.ai\";\ntitle.style.color = t.ink;\ntitle.style.fontSize = \"22px\";\ntitle.style.fontWeight = \"600\";\ntitle.style.textAlign = \"center\";\ntitle.style.marginBottom = \"10px\";\ncontainer.appendChild(title);\n\nconst legendRow = document.createElement(\"div\");\nlegendRow.style.display = \"flex\";\nlegendRow.style.justifyContent = \"center\";\nlegendRow.style.gap = \"22px\";\nlegendRow.style.marginBottom = \"14px\";\nfor (const spec of speciesSpecs) {\n  const item = document.createElement(\"div\");\n  item.style.display = \"flex\";\n  item.style.alignItems = \"center\";\n  item.style.gap = \"6px\";\n  const swatch = document.createElement(\"span\");\n  swatch.style.width = \"13px\";\n  swatch.style.height = \"13px\";\n  swatch.style.borderRadius = \"3px\";\n  swatch.style.background = spec.color;\n  swatch.style.display = \"inline-block\";\n  const label = document.createElement(\"span\");\n  label.textContent = spec.name;\n  label.style.color = t.ink;\n  label.style.fontSize = \"15px\";\n  item.appendChild(swatch);\n  item.appendChild(label);\n  legendRow.appendChild(item);\n}\ncontainer.appendChild(legendRow);\n\nconst gridArea = document.createElement(\"div\");\ngridArea.style.display = \"flex\";\ngridArea.style.flexDirection = \"column\";\ngridArea.style.flex = \"1\";\ngridArea.style.minHeight = \"0\";\ncontainer.appendChild(gridArea);\n\nconst rowsWrap = document.createElement(\"div\");\nrowsWrap.style.display = \"flex\";\nrowsWrap.style.flexDirection = \"column\";\nrowsWrap.style.flex = \"1\";\nrowsWrap.style.minHeight = \"0\";\nrowsWrap.style.gap = \"6px\";\ngridArea.appendChild(rowsWrap);\n\nconst ROW_LABEL_WIDTH = \"34px\";\n\nvariables.forEach((rowVar, rowIdx) => {\n  const rowDiv = document.createElement(\"div\");\n  rowDiv.style.display = \"flex\";\n  rowDiv.style.flex = \"1\";\n  rowDiv.style.minHeight = \"0\";\n  rowDiv.style.gap = \"6px\";\n\n  const rowLabel = document.createElement(\"div\");\n  rowLabel.textContent = rowVar.label;\n  rowLabel.style.width = ROW_LABEL_WIDTH;\n  rowLabel.style.flexShrink = \"0\";\n  rowLabel.style.display = \"flex\";\n  rowLabel.style.alignItems = \"center\";\n  rowLabel.style.justifyContent = \"center\";\n  rowLabel.style.writingMode = \"vertical-rl\";\n  rowLabel.style.transform = \"rotate(180deg)\";\n  rowLabel.style.color = t.ink;\n  rowLabel.style.fontSize = \"14px\";\n  rowLabel.style.fontWeight = \"600\";\n  rowLabel.style.background = t.elevatedBg;\n  rowLabel.style.borderRadius = \"6px\";\n  rowDiv.appendChild(rowLabel);\n\n  variables.forEach((colVar, colIdx) => {\n    const isLeftCol = colIdx === 0;\n    const isBottomRow = rowIdx === N - 1;\n    const isDiagonal = rowVar.key === colVar.key;\n\n    const cellWrap = document.createElement(\"div\");\n    cellWrap.style.position = \"relative\";\n    cellWrap.style.flex = \"1\";\n    cellWrap.style.minWidth = \"0\";\n    cellWrap.style.background = t.elevatedBg;\n    // Only edge cells (which anchor the row/column labels) and diagonal\n    // histograms keep a visible border; interior scatter cells stay\n    // borderless so the grid reads as one panel instead of 16 boxed tiles.\n    cellWrap.style.border =\n      isDiagonal || isLeftCol || isBottomRow\n        ? `1px solid ${t.grid}80`\n        : \"1px solid transparent\";\n    cellWrap.style.borderRadius = \"6px\";\n    cellWrap.style.overflow = \"hidden\";\n    if (isDiagonal) {\n      // A touch of breathing room around each diagonal histogram.\n      cellWrap.style.padding = \"3px\";\n    }\n\n    const canvas = document.createElement(\"canvas\");\n    cellWrap.appendChild(canvas);\n    rowDiv.appendChild(cellWrap);\n\n    if (isDiagonal) {\n      // Diagonal: stacked histogram showing the univariate distribution\n      const { binLabels, counts } = histogramBySpecies(rowVar.key);\n      new Chart(canvas, {\n        type: \"bar\",\n        data: {\n          labels: binLabels,\n          datasets: speciesSpecs.map((spec) => ({\n            label: spec.name,\n            data: counts[spec.name],\n            backgroundColor: spec.color,\n            borderColor: t.pageBg,\n            borderWidth: 1,\n            stack: \"dist\",\n          })),\n        },\n        options: {\n          responsive: true,\n          maintainAspectRatio: false,\n          animation: false,\n          plugins: {\n            legend: { display: false },\n            title: { display: false },\n            tooltip: {\n              callbacks: {\n                title: (items) => `${rowVar.label} ≈ ${items[0].label}`,\n              },\n            },\n          },\n          scales: {\n            x: {\n              stacked: true,\n              ticks: {\n                display: isBottomRow,\n                color: t.inkSoft,\n                font: { size: 14 },\n                maxTicksLimit: 4,\n              },\n              grid: { display: false },\n            },\n            y: {\n              stacked: true,\n              ticks: {\n                display: isLeftCol,\n                color: t.inkSoft,\n                font: { size: 14 },\n                maxTicksLimit: 3,\n              },\n              grid: { color: t.grid },\n            },\n          },\n        },\n      });\n    } else {\n      // Off-diagonal: pairwise scatter, colored by species. The\n      // petal-length/petal-width cells (the dataset's strongest pairwise\n      // correlation) get a touch more marker weight plus the focusAccent\n      // plugin outline to sharpen that focal relationship.\n      const isFocalPair =\n        FOCUS_PAIR.has(rowVar.key) && FOCUS_PAIR.has(colVar.key);\n      new Chart(canvas, {\n        type: \"scatter\",\n        data: {\n          datasets: speciesSpecs.map((spec) => ({\n            label: spec.name,\n            data: records\n              .filter((r) => r.species === spec.name)\n              .map((r) => ({ x: r[colVar.key], y: r[rowVar.key] })),\n            backgroundColor: `${spec.color}${isFocalPair ? \"C2\" : \"A6\"}`,\n            pointRadius: isFocalPair ? 4 : 3,\n            pointHoverRadius: isFocalPair ? 5 : 4,\n          })),\n        },\n        options: {\n          responsive: true,\n          maintainAspectRatio: false,\n          animation: false,\n          plugins: {\n            legend: { display: false },\n            title: { display: false },\n            focusAccent: { active: isFocalPair, color: t.palette[0] },\n            tooltip: {\n              callbacks: {\n                label: (ctx) =>\n                  `${ctx.dataset.label}: ${colVar.label} ${ctx.parsed.x.toFixed(2)}, ${rowVar.label} ${ctx.parsed.y.toFixed(2)}`,\n              },\n            },\n          },\n          scales: {\n            x: {\n              min: domainByKey[colVar.key].min,\n              max: domainByKey[colVar.key].max,\n              ticks: {\n                display: isBottomRow,\n                color: t.inkSoft,\n                font: { size: 14 },\n                maxTicksLimit: 4,\n              },\n              grid: { color: t.grid },\n            },\n            y: {\n              min: domainByKey[rowVar.key].min,\n              max: domainByKey[rowVar.key].max,\n              ticks: {\n                display: isLeftCol,\n                color: t.inkSoft,\n                font: { size: 14 },\n                maxTicksLimit: 4,\n              },\n              grid: { color: t.grid },\n            },\n          },\n        },\n      });\n    }\n  });\n\n  rowsWrap.appendChild(rowDiv);\n});\n\n// Column variable names along the bottom edge only\nconst colLabelsRow = document.createElement(\"div\");\ncolLabelsRow.style.display = \"flex\";\ncolLabelsRow.style.gap = \"6px\";\ncolLabelsRow.style.marginTop = \"8px\";\nconst colLabelsSpacer = document.createElement(\"div\");\ncolLabelsSpacer.style.width = ROW_LABEL_WIDTH;\ncolLabelsSpacer.style.flexShrink = \"0\";\ncolLabelsRow.appendChild(colLabelsSpacer);\nfor (const colVar of variables) {\n  const cell = document.createElement(\"div\");\n  cell.textContent = colVar.label;\n  cell.style.flex = \"1\";\n  cell.style.textAlign = \"center\";\n  cell.style.color = t.ink;\n  cell.style.fontSize = \"14px\";\n  cell.style.fontWeight = \"600\";\n  colLabelsRow.appendChild(cell);\n}\ngridArea.appendChild(colLabelsRow);\n"}