{"spec_id":"heatmap-clustered","library":"echarts","language":"javascript","code":"// anyplot.ai\n// heatmap-clustered: Clustered Heatmap\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-05\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// 24 customers profiled on 10 behavioral metrics, drawn from 4 latent\n// segments so the clustering has real structure to recover. A small\n// fixed-seed LCG + Box-Muller stands in for a seeded RNG (the browser has\n// none).\nlet lcgState = 42;\nconst lcgUniform = () => {\n  lcgState = (lcgState * 1664525 + 1013904223) % 4294967296;\n  return lcgState / 4294967296;\n};\nconst lcgGaussian = () => {\n  const u1 = Math.max(lcgUniform(), 1e-9);\n  const u2 = lcgUniform();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\n\nconst metrics = [\n  \"Purchase Frequency\",\n  \"Avg Order Value\",\n  \"Cart Abandonment\",\n  \"Email Open Rate\",\n  \"Discount Usage\",\n  \"Return Rate\",\n  \"Session Duration\",\n  \"Referrals Made\",\n  \"Loyalty Points\",\n  \"Support Tickets\",\n];\n\nconst segments = [\n  { name: \"Bargain Hunters\", profile: [-0.5, -1.2, 1.5, 0.2, 1.8, 1.3, 0.3, -0.8, -0.9, 0.6] },\n  { name: \"Loyal Regulars\", profile: [1.6, 0.8, -1.3, 1.2, -0.9, -1.0, 0.4, 1.5, 1.7, -0.6] },\n  { name: \"Window Shoppers\", profile: [-1.4, -0.9, 1.2, -0.3, 0.2, 0.4, 1.6, -1.1, -1.2, 0.9] },\n  { name: \"Big Spenders\", profile: [1.1, 1.7, -1.0, -0.4, -1.1, -0.7, -0.6, 0.7, 0.8, -0.5] },\n];\n\nconst customers = [];\nconst rawMatrix = [];\nsegments.forEach((segment, s) => {\n  for (let k = 0; k < 6; k++) {\n    customers.push(`Customer ${s * 6 + k + 1}`);\n    rawMatrix.push(segment.profile.map((mean) => mean + lcgGaussian() * 0.35));\n  }\n});\n\n// Z-score each metric column across all customers so the matrix is centered\n// on zero — required for the diverging colormap to read correctly.\nconst nRows = rawMatrix.length;\nconst matrix = rawMatrix.map((row) => row.slice());\nfor (let col = 0; col < metrics.length; col++) {\n  const column = matrix.map((row) => row[col]);\n  const mean = column.reduce((a, b) => a + b, 0) / nRows;\n  const variance = column.reduce((a, v) => a + (v - mean) ** 2, 0) / nRows;\n  const std = Math.sqrt(variance) || 1;\n  for (let row = 0; row < nRows; row++) matrix[row][col] = (matrix[row][col] - mean) / std;\n}\n\n// --- Hierarchical clustering (Ward's method, Euclidean distance) ------------\n// Agglomerative clustering over arbitrary vectors. Ward's linkage distance\n// between two clusters is computed directly from their centroids and sizes —\n// mathematically equivalent to the Lance-Williams recursion — so heights are\n// non-decreasing and comparable across the whole tree.\nconst wardCluster = (vectors) => {\n  const n = vectors.length;\n  const euclidean = (a, b) => Math.sqrt(a.reduce((s, v, i) => s + (v - b[i]) ** 2, 0));\n\n  const centroid = new Map();\n  const size = new Map();\n  const height = new Map();\n  const left = new Map();\n  const right = new Map();\n  const active = new Set();\n  for (let i = 0; i < n; i++) {\n    centroid.set(i, vectors[i]);\n    size.set(i, 1);\n    height.set(i, 0);\n    active.add(i);\n  }\n\n  const wardDistance = (i, j) => {\n    const ni = size.get(i);\n    const nj = size.get(j);\n    const factor = Math.sqrt((2 * ni * nj) / (ni + nj));\n    return factor * euclidean(centroid.get(i), centroid.get(j));\n  };\n\n  let nextId = n;\n  for (let step = 0; step < n - 1; step++) {\n    const activeList = [...active];\n    let bestI = -1;\n    let bestJ = -1;\n    let bestDist = Infinity;\n    for (let a = 0; a < activeList.length; a++) {\n      for (let b = a + 1; b < activeList.length; b++) {\n        const d = wardDistance(activeList[a], activeList[b]);\n        if (d < bestDist) {\n          bestDist = d;\n          bestI = activeList[a];\n          bestJ = activeList[b];\n        }\n      }\n    }\n    const ni = size.get(bestI);\n    const nj = size.get(bestJ);\n    const merged = centroid.get(bestI).map((v, k) => (v * ni + centroid.get(bestJ)[k] * nj) / (ni + nj));\n    const id = nextId++;\n    centroid.set(id, merged);\n    size.set(id, ni + nj);\n    height.set(id, bestDist);\n    left.set(id, bestI);\n    right.set(id, bestJ);\n    active.delete(bestI);\n    active.delete(bestJ);\n    active.add(id);\n  }\n\n  const root = nextId - 1;\n  const leafOrder = [];\n  const collectLeaves = (id) => {\n    if (id < n) {\n      leafOrder.push(id);\n      return;\n    }\n    collectLeaves(left.get(id));\n    collectLeaves(right.get(id));\n  };\n  collectLeaves(root);\n\n  const position = new Map();\n  leafOrder.forEach((leafId, rank) => position.set(leafId, rank));\n  const merges = [];\n  for (let id = n; id < nextId; id++) {\n    const l = left.get(id);\n    const r = right.get(id);\n    const parentPos = (position.get(l) + position.get(r)) / 2;\n    position.set(id, parentPos);\n    merges.push({\n      posA: position.get(l),\n      heightA: height.get(l),\n      posB: position.get(r),\n      heightB: height.get(r),\n      heightP: height.get(id),\n    });\n  }\n\n  return { leafOrder, merges, maxHeight: height.get(root) };\n};\n\nconst rowClusters = wardCluster(matrix);\nconst colVectors = metrics.map((_, col) => matrix.map((row) => row[col]));\nconst colClusters = wardCluster(colVectors);\n\nconst orderedRows = rowClusters.leafOrder.map((i) => customers[i]);\nconst orderedCols = colClusters.leafOrder.map((j) => metrics[j]);\nconst orderedMatrix = rowClusters.leafOrder.map((i) => colClusters.leafOrder.map((j) => matrix[i][j]));\n\n// --- Heatmap cells + colorbar range ------------------------------------------\nconst heatmapData = [];\nlet maxAbsValue = 0;\nfor (let r = 0; r < orderedRows.length; r++) {\n  for (let c = 0; c < orderedCols.length; c++) {\n    const value = orderedMatrix[r][c];\n    heatmapData.push([c, r, value]);\n    maxAbsValue = Math.max(maxAbsValue, Math.abs(value));\n  }\n}\n\n// --- Dendrogram bracket data (one row per merge) -----------------------------\nconst colDendroData = colClusters.merges.map((m) => [m.posA, m.heightA, m.posB, m.heightB, m.heightP]);\nconst rowDendroData = rowClusters.merges.map((m) => [m.heightA, m.posA, m.heightB, m.posB, m.heightP]);\n\nconst colRenderItem = (params, api) => {\n  const xA = api.value(0);\n  const hA = api.value(1);\n  const xB = api.value(2);\n  const hB = api.value(3);\n  const hP = api.value(4);\n  return {\n    type: \"polyline\",\n    shape: { points: [api.coord([xA, hA]), api.coord([xA, hP]), api.coord([xB, hP]), api.coord([xB, hB])] },\n    style: { stroke: t.inkSoft, lineWidth: 1.6, fill: \"none\" },\n  };\n};\n\nconst rowRenderItem = (params, api) => {\n  const hA = api.value(0);\n  const yA = api.value(1);\n  const hB = api.value(2);\n  const yB = api.value(3);\n  const hP = api.value(4);\n  return {\n    type: \"polyline\",\n    shape: { points: [api.coord([hA, yA]), api.coord([hP, yA]), api.coord([hP, yB]), api.coord([hB, yB])] },\n    style: { stroke: t.inkSoft, lineWidth: 1.6, fill: \"none\" },\n  };\n};\n\n// --- Init ---------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Title (scaled to length per anyplot title-fontsize rule) ---------------\nconst title = \"heatmap-clustered · javascript · echarts · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / title.length));\n\n// --- Layout: heatmap grid + a dendrogram grid on each of its two edges ------\nconst GRID_LEFT = 195;\nconst GRID_RIGHT = 250;\nconst GRID_TOP = 210;\nconst GRID_BOTTOM = 180;\nconst ROW_DENDRO_LEFT = 68;\nconst ROW_DENDRO_WIDTH = 108;\nconst COL_DENDRO_TOP = 98;\nconst COL_DENDRO_HEIGHT = 102;\n\n// --- Option -------------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: title,\n    subtext: \"Customer behavior metrics reordered by Ward's hierarchical clustering (Euclidean distance)\",\n    left: \"center\",\n    top: 22,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n    subtextStyle: { color: t.inkSoft, fontSize: 14 },\n  },\n  grid: [\n    { left: GRID_LEFT, right: GRID_RIGHT, top: GRID_TOP, bottom: GRID_BOTTOM },\n    { left: GRID_LEFT, right: GRID_RIGHT, top: COL_DENDRO_TOP, height: COL_DENDRO_HEIGHT },\n    { left: ROW_DENDRO_LEFT, width: ROW_DENDRO_WIDTH, top: GRID_TOP, bottom: GRID_BOTTOM },\n  ],\n  xAxis: [\n    {\n      type: \"category\",\n      gridIndex: 0,\n      data: orderedCols,\n      axisLine: { lineStyle: { color: t.inkSoft } },\n      axisTick: { show: false },\n      axisLabel: { color: t.inkSoft, fontSize: 14, rotate: 45 },\n    },\n    {\n      type: \"category\",\n      gridIndex: 1,\n      data: orderedCols,\n      show: false,\n    },\n    {\n      type: \"value\",\n      gridIndex: 2,\n      min: 0,\n      max: rowClusters.maxHeight * 1.08,\n      inverse: true,\n      show: false,\n    },\n  ],\n  yAxis: [\n    {\n      type: \"category\",\n      gridIndex: 0,\n      data: orderedRows,\n      inverse: true,\n      position: \"right\",\n      axisLine: { lineStyle: { color: t.inkSoft } },\n      axisTick: { show: false },\n      axisLabel: { color: t.inkSoft, fontSize: 13 },\n    },\n    {\n      type: \"value\",\n      gridIndex: 1,\n      min: 0,\n      max: colClusters.maxHeight * 1.08,\n      show: false,\n    },\n    {\n      type: \"category\",\n      gridIndex: 2,\n      data: orderedRows,\n      inverse: true,\n      show: false,\n    },\n  ],\n  visualMap: {\n    type: \"continuous\",\n    min: -maxAbsValue,\n    max: maxAbsValue,\n    calculable: false,\n    orient: \"vertical\",\n    right: 40,\n    top: GRID_TOP,\n    itemHeight: 420,\n    itemWidth: 22,\n    inRange: { color: t.div },\n    text: [`+${maxAbsValue.toFixed(1)}σ`, `-${maxAbsValue.toFixed(1)}σ`],\n    textStyle: { color: t.inkSoft, fontSize: 13 },\n  },\n  series: [\n    {\n      type: \"heatmap\",\n      xAxisIndex: 0,\n      yAxisIndex: 0,\n      data: heatmapData,\n      itemStyle: { borderColor: t.pageBg, borderWidth: 1 },\n    },\n    {\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      xAxisIndex: 1,\n      yAxisIndex: 1,\n      data: colDendroData,\n      renderItem: colRenderItem,\n      clip: false,\n      silent: true,\n    },\n    {\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      xAxisIndex: 2,\n      yAxisIndex: 2,\n      data: rowDendroData,\n      renderItem: rowRenderItem,\n      clip: false,\n      silent: true,\n    },\n  ],\n});\n"}