{"spec_id":"chernoff-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// chernoff-basic: Chernoff Faces for Multivariate Data\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: 12 companies x 8 financial/operational metrics, 3 industries ----\n// Each metric is later normalized to [0, 1] (via d3.extent) before being\n// mapped onto a facial feature's pixel range, per the spec's 0-1 rule.\nconst companies = [\n  { name: \"Nova Robotics\", industry: \"Tech\", revenueGrowth: 24, profitMargin: 15, liquidityRatio: 2.1, rdInvestment: 19, debtRatio: 0.28, marketShare: 9, customerSat: 86, opEfficiency: 78 },\n  { name: \"PixelForge Software\", industry: \"Tech\", revenueGrowth: 31, profitMargin: 8, liquidityRatio: 1.6, rdInvestment: 22, debtRatio: 0.35, marketShare: 6, customerSat: 79, opEfficiency: 71 },\n  { name: \"CloudSpring Systems\", industry: \"Tech\", revenueGrowth: 12, profitMargin: 22, liquidityRatio: 2.8, rdInvestment: 14, debtRatio: 0.18, marketShare: 13, customerSat: 91, opEfficiency: 88 },\n  { name: \"ByteHarbor Data\", industry: \"Tech\", revenueGrowth: 6, profitMargin: 5, liquidityRatio: 1.1, rdInvestment: 9, debtRatio: 0.52, marketShare: 4, customerSat: 63, opEfficiency: 58 },\n  { name: \"Trailhead Retail\", industry: \"Retail\", revenueGrowth: 9, profitMargin: 6, liquidityRatio: 1.4, rdInvestment: 3, debtRatio: 0.41, marketShare: 11, customerSat: 74, opEfficiency: 69 },\n  { name: \"UrbanCart\", industry: \"Retail\", revenueGrowth: 15, profitMargin: 9, liquidityRatio: 1.7, rdInvestment: 4, debtRatio: 0.33, marketShare: 15, customerSat: 81, opEfficiency: 75 },\n  { name: \"Meadow Market\", industry: \"Retail\", revenueGrowth: 3, profitMargin: 4, liquidityRatio: 0.9, rdInvestment: 2, debtRatio: 0.61, marketShare: 7, customerSat: 58, opEfficiency: 52 },\n  { name: \"Northwind Goods\", industry: \"Retail\", revenueGrowth: 11, profitMargin: 12, liquidityRatio: 2.0, rdInvestment: 5, debtRatio: 0.24, marketShare: 19, customerSat: 84, opEfficiency: 80 },\n  { name: \"Ironclad Manufacturing\", industry: \"Manufacturing\", revenueGrowth: 7, profitMargin: 14, liquidityRatio: 1.9, rdInvestment: 8, debtRatio: 0.3, marketShare: 22, customerSat: 77, opEfficiency: 83 },\n  { name: \"Summit Steel Works\", industry: \"Manufacturing\", revenueGrowth: 4, profitMargin: 10, liquidityRatio: 1.5, rdInvestment: 6, debtRatio: 0.44, marketShare: 17, customerSat: 70, opEfficiency: 76 },\n  { name: \"Cascade Motors\", industry: \"Manufacturing\", revenueGrowth: 13, profitMargin: 17, liquidityRatio: 2.3, rdInvestment: 11, debtRatio: 0.2, marketShare: 24, customerSat: 89, opEfficiency: 85 },\n  { name: \"Anchor Industries\", industry: \"Manufacturing\", revenueGrowth: 1, profitMargin: 2, liquidityRatio: 0.8, rdInvestment: 3, debtRatio: 0.58, marketShare: 10, customerSat: 55, opEfficiency: 48 },\n];\n\nconst industries = [\"Tech\", \"Retail\", \"Manufacturing\"];\nconst industryColor = d3.scaleOrdinal().domain(industries).range(t.palette.slice(0, 3));\n\n// --- Explicit 0-1 normalization, then mapped onto a facial-feature pixel range --\nconst normalize = (accessor) => {\n  const [lo, hi] = d3.extent(companies, accessor);\n  return (d) => (accessor(d) - lo) / (hi - lo);\n};\nconst toRange = (norm, range) => (d) => range[0] + norm(d) * (range[1] - range[0]);\n\nconst faceWidthScale = toRange(normalize((d) => d.revenueGrowth), [55, 85]);\nconst faceHeightScale = toRange(normalize((d) => d.profitMargin), [65, 95]);\nconst eyeSizeScale = toRange(normalize((d) => d.liquidityRatio), [5, 11]);\nconst eyeSpacingScale = toRange(normalize((d) => d.rdInvestment), [16, 30]);\nconst browSlantScale = toRange(normalize((d) => d.debtRatio), [-8, 24]);\nconst noseLengthScale = toRange(normalize((d) => d.marketShare), [10, 24]);\nconst mouthCurveScale = toRange(normalize((d) => d.customerSat), [-10, 22]);\nconst mouthWidthScale = toRange(normalize((d) => d.opEfficiency), [20, 40]);\n\n// composite risk score (high debt, weak everything else) — flags the one\n// outlier face to receive a dashed amber emphasis ring below\nconst riskScore = (d) =>\n  normalize((c) => c.debtRatio)(d) -\n  (normalize((c) => c.revenueGrowth)(d) +\n    normalize((c) => c.profitMargin)(d) +\n    normalize((c) => c.liquidityRatio)(d) +\n    normalize((c) => c.customerSat)(d) +\n    normalize((c) => c.opEfficiency)(d)) /\n    5;\nconst weakestCompany = companies.reduce((worst, d) => (riskScore(d) > riskScore(worst) ? d : worst));\n\n// --- Grid layout --------------------------------------------------------------\nconst margin = { top: 130, right: 40, bottom: 20, left: 40 };\nconst cols = 4;\nconst rows = 3;\nconst cellW = (width - margin.left - margin.right) / cols;\nconst cellH = (height - margin.top - margin.bottom) / rows;\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Subtle drop-shadow for face outlines (visual refinement) ---------------\nsvg\n  .append(\"defs\")\n  .append(\"filter\")\n  .attr(\"id\", \"face-shadow\")\n  .attr(\"x\", \"-50%\")\n  .attr(\"y\", \"-50%\")\n  .attr(\"width\", \"200%\")\n  .attr(\"height\", \"200%\")\n  .append(\"feDropShadow\")\n  .attr(\"dx\", 0)\n  .attr(\"dy\", 3)\n  .attr(\"stdDeviation\", 3)\n  .attr(\"flood-color\", t.ink)\n  .attr(\"flood-opacity\", 0.18);\n\n// --- Title ----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"chernoff-basic · javascript · d3 · anyplot.ai\");\n\n// --- Industry legend --------------------------------------------------------\nconst legendItemW = 190;\nconst legend = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${width / 2 - ((industries.length - 1) * legendItemW) / 2}, 92)`);\n\nconst legendItems = legend\n  .selectAll(\"g.legend-item\")\n  .data(industries)\n  .join(\"g\")\n  .attr(\"class\", \"legend-item\")\n  .attr(\"transform\", (d, i) => `translate(${i * legendItemW}, 0)`);\n\nlegendItems.append(\"circle\").attr(\"r\", 7).attr(\"fill\", (d) => industryColor(d));\nlegendItems\n  .append(\"text\")\n  .attr(\"x\", 16)\n  .attr(\"y\", 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text((d) => d);\n\n// --- Faces: one <g> per company, positioned by grid index -------------------\nconst faceG = svg\n  .selectAll(\"g.face\")\n  .data(companies)\n  .join(\"g\")\n  .attr(\"class\", \"face\")\n  .attr(\"transform\", (d, i) => {\n    const col = i % cols;\n    const row = Math.floor(i / cols);\n    const cx = margin.left + col * cellW + cellW / 2;\n    const cy = margin.top + row * cellH + cellH * 0.4;\n    return `translate(${cx},${cy})`;\n  });\n\nfaceG.each(function (d) {\n  const g = d3.select(this);\n  const color = industryColor(d.industry);\n\n  const faceW = faceWidthScale(d);\n  const faceH = faceHeightScale(d);\n  const eyeSize = eyeSizeScale(d);\n  const eyeSpacing = eyeSpacingScale(d);\n  const browSlant = browSlantScale(d);\n  const noseLen = noseLengthScale(d);\n  const mouthCurve = mouthCurveScale(d);\n  const mouthWidth = mouthWidthScale(d);\n\n  const eyeY = -faceH * 0.12;\n  const noseTopY = -faceH * 0.05;\n  const mouthY = faceH * 0.42;\n\n  // dashed amber ring flags the single weakest-fundamentals company (high debt,\n  // low growth/margin/liquidity/satisfaction/efficiency) — an emphasis outlier\n  if (d === weakestCompany) {\n    g.append(\"ellipse\")\n      .attr(\"cx\", 0)\n      .attr(\"cy\", 0)\n      .attr(\"rx\", faceW + 9)\n      .attr(\"ry\", faceH + 9)\n      .attr(\"fill\", \"none\")\n      .attr(\"stroke\", t.amber)\n      .attr(\"stroke-width\", 2)\n      .attr(\"stroke-dasharray\", \"5 4\");\n  }\n\n  // face outline — industry color carries the group encoding\n  g.append(\"ellipse\")\n    .attr(\"cx\", 0)\n    .attr(\"cy\", 0)\n    .attr(\"rx\", faceW)\n    .attr(\"ry\", faceH)\n    .attr(\"fill\", t.elevatedBg)\n    .attr(\"stroke\", color)\n    .attr(\"stroke-width\", 3.5)\n    .attr(\"filter\", \"url(#face-shadow)\");\n\n  // eyes: white + pupil, mirrored around center\n  for (const side of [-1, 1]) {\n    const ex = side * eyeSpacing;\n    g.append(\"ellipse\").attr(\"cx\", ex).attr(\"cy\", eyeY).attr(\"rx\", eyeSize).attr(\"ry\", eyeSize * 0.8).attr(\"fill\", t.pageBg).attr(\"stroke\", t.ink).attr(\"stroke-width\", 1.5);\n    g.append(\"circle\").attr(\"cx\", ex).attr(\"cy\", eyeY).attr(\"r\", eyeSize * 0.4).attr(\"fill\", t.ink);\n\n    // eyebrow, rotated by debt-driven slant (mirrored across the two sides)\n    const browY = eyeY - eyeSize - 10;\n    g.append(\"line\")\n      .attr(\"x1\", ex - 13)\n      .attr(\"x2\", ex + 13)\n      .attr(\"y1\", browY)\n      .attr(\"y2\", browY)\n      .attr(\"stroke\", t.ink)\n      .attr(\"stroke-width\", 3)\n      .attr(\"stroke-linecap\", \"round\")\n      .attr(\"transform\", `rotate(${side * browSlant} ${ex} ${browY})`);\n  }\n\n  // nose\n  g.append(\"path\")\n    .attr(\"d\", `M0,${noseTopY} L0,${noseTopY + noseLen} L5,${noseTopY + noseLen}`)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 2.5)\n    .attr(\"stroke-linecap\", \"round\");\n\n  // mouth: quadratic curve, control point below/above the corners for smile/frown\n  g.append(\"path\")\n    .attr(\"d\", `M${-mouthWidth / 2},${mouthY} Q0,${mouthY + mouthCurve} ${mouthWidth / 2},${mouthY}`)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 3)\n    .attr(\"stroke-linecap\", \"round\");\n\n  // company label\n  g.append(\"text\")\n    .attr(\"x\", 0)\n    .attr(\"y\", cellH * 0.46)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"16px\")\n    .text(d.name);\n});\n"}