{"spec_id":"scatter-complex-plane","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// scatter-complex-plane: Complex Plane Visualization (Argand Diagram)\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-26\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Cube roots of unity (radius 1, spaced 120° apart) — a classic Argand-plane\n// example that ties directly into z^3 = 1 factorization.\nconst rootsOfUnity = [0, 1, 2].map((k) => {\n  const theta = (k * 2 * Math.PI) / 3;\n  return { x: Math.cos(theta), y: Math.sin(theta), label: k === 0 ? \"1\" : k === 1 ? \"ω\" : \"ω²\" };\n});\n\n// Two arbitrary complex numbers plus their sum, illustrating the\n// parallelogram law of complex addition.\nconst z1 = { x: 1.8, y: 1.2, label: \"z₁\" };\nconst z2 = { x: 0.6, y: -1.5, label: \"z₂\" };\nconst zSum = { x: z1.x + z2.x, y: z1.y + z2.y, label: \"z₁+z₂\" };\nconst arbitraryPoints = [z1, z2, zSum];\n\nconst AXIS_LIMIT = 3;\n\n// Grid already sits at 15% alpha (the theme-token ceiling); shave it down a\n// touch further so it stays firmly behind the vectors and unit circle.\nconst softGrid = t.grid.replace(/[\\d.]+\\)$/, \"0.09)\");\n\n// --- Formatting helpers ------------------------------------------------------\nfunction formatRectangular(p) {\n  const sign = p.y >= 0 ? \"+\" : \"−\";\n  return `${p.x.toFixed(2)} ${sign} ${Math.abs(p.y).toFixed(2)}i`;\n}\n\nfunction formatPolar(p) {\n  const r = Math.hypot(p.x, p.y);\n  const theta = (Math.atan2(p.y, p.x) * 180) / Math.PI;\n  return `r=${r.toFixed(2)}, θ=${theta.toFixed(0)}°`;\n}\n\n// --- Mount --------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Custom plugin: origin vectors, unit circle, point annotations ----------\n// Chart.js ships no annotation/arrow plugin, so the Argand-diagram furniture\n// (vectors from the origin, the unit-circle reference, rectangular/polar\n// labels) is drawn with a small inline plugin using the scatter controller's\n// own pixel geometry — the documented way to extend core Chart.js rendering.\nconst argandFurniture = {\n  id: \"argandFurniture\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales, chartArea } = chart;\n    const ox = scales.x.getPixelForValue(0);\n    const oy = scales.y.getPixelForValue(0);\n    const unitPx = Math.abs(scales.x.getPixelForValue(1) - ox);\n\n    // Unit circle — dashed reference for |z| = 1.\n    ctx.save();\n    ctx.setLineDash([6, 6]);\n    ctx.lineWidth = 1.5;\n    ctx.strokeStyle = t.inkSoft;\n    ctx.globalAlpha = 0.6;\n    ctx.beginPath();\n    ctx.arc(ox, oy, unitPx, 0, Math.PI * 2);\n    ctx.stroke();\n    ctx.restore();\n\n    // Parallelogram guide: dashed edges from z1 to z1+z2 and from z2 to\n    // z1+z2 (each edge is a translated copy of the *other* vector, since\n    // zSum - z1 = z2 and zSum - z2 = z1) — makes the addition law visible\n    // as a shape, not just three separate arrows.\n    const sumDataset = chart.data.datasets[1];\n    const sumMeta = chart.getDatasetMeta(1);\n    if (sumMeta && sumMeta.data.length === 3) {\n      const [pz1, pz2, pSum] = sumMeta.data;\n      ctx.save();\n      ctx.setLineDash([5, 5]);\n      ctx.lineWidth = 1.5;\n      ctx.strokeStyle = sumDataset.borderColor;\n      ctx.globalAlpha = 0.35;\n      ctx.beginPath();\n      ctx.moveTo(pz1.x, pz1.y);\n      ctx.lineTo(pSum.x, pSum.y);\n      ctx.moveTo(pz2.x, pz2.y);\n      ctx.lineTo(pSum.x, pSum.y);\n      ctx.stroke();\n      ctx.restore();\n    }\n\n    // Vectors from the origin to every point, arrowhead at the tip.\n    chart.data.datasets.forEach((dataset, di) => {\n      const meta = chart.getDatasetMeta(di);\n      meta.data.forEach((point) => {\n        const dx = point.x - ox;\n        const dy = point.y - oy;\n        const len = Math.hypot(dx, dy);\n        if (len < 1e-6) return;\n        const ux = dx / len;\n        const uy = dy / len;\n        const head = 14;\n\n        ctx.save();\n        ctx.strokeStyle = dataset.borderColor;\n        ctx.fillStyle = dataset.borderColor;\n        ctx.lineWidth = 2.5;\n        ctx.globalAlpha = 0.85;\n        ctx.beginPath();\n        ctx.moveTo(ox, oy);\n        ctx.lineTo(point.x - ux * head * 0.6, point.y - uy * head * 0.6);\n        ctx.stroke();\n\n        const perpX = -uy;\n        const perpY = ux;\n        ctx.beginPath();\n        ctx.moveTo(point.x, point.y);\n        ctx.lineTo(point.x - ux * head + perpX * head * 0.4, point.y - uy * head + perpY * head * 0.4);\n        ctx.lineTo(point.x - ux * head - perpX * head * 0.4, point.y - uy * head - perpY * head * 0.4);\n        ctx.closePath();\n        ctx.fill();\n        ctx.restore();\n      });\n    });\n\n    // Real / Imaginary axis-end labels, drawn manually near the axis tips.\n    // A `scale.title` would work for edge-positioned axes, but these axes are\n    // centered on the origin (position: {x:0}/{y:0}) — its title tracks the\n    // axis line itself and would land under the chart title at the top edge.\n    ctx.save();\n    ctx.font = \"600 16px -apple-system, Helvetica, Arial, sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.textBaseline = \"bottom\";\n    ctx.textAlign = \"right\";\n    ctx.fillText(\"Real\", chartArea.right, oy - 10);\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"Imaginary\", ox + 10, chartArea.top + 18);\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    chart.data.datasets.forEach((dataset, di) => {\n      const meta = chart.getDatasetMeta(di);\n      meta.data.forEach((point, i) => {\n        const raw = dataset.data[i];\n        const above = raw.y >= 0;\n        const lineHeight = 18;\n        const baseY = above ? point.y - 16 : point.y + 16;\n        const dir = above ? -1 : 1;\n\n        ctx.save();\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = above ? \"bottom\" : \"top\";\n\n        ctx.font = \"600 16px -apple-system, Helvetica, Arial, sans-serif\";\n        ctx.fillStyle = dataset.borderColor;\n        ctx.fillText(raw.label, point.x, baseY);\n\n        ctx.font = \"14px -apple-system, Helvetica, Arial, sans-serif\";\n        ctx.fillStyle = t.inkSoft;\n        ctx.fillText(formatRectangular(raw), point.x, baseY + dir * lineHeight);\n        ctx.fillText(formatPolar(raw), point.x, baseY + dir * lineHeight * 2);\n        ctx.restore();\n      });\n    });\n  },\n  afterRender(chart) {\n    // Chart.js lays scales out independently, so an initial pass rarely\n    // yields equal pixels-per-unit on x and y. Once real layout pixels are\n    // known, tighten one axis' range to match the other's scale exactly (the\n    // unit circle must render as a circle, per the spec's equal-aspect rule),\n    // then redraw once. A guard flag keeps this to a single extra pass.\n    if (chart.$argandAspectFixed) {\n      window.__anyplotReady = true;\n      return;\n    }\n    const { chartArea, scales } = chart;\n    const { x, y } = scales;\n    const xCenter = (x.max + x.min) / 2;\n    const yCenter = (y.max + y.min) / 2;\n    const pxPerUnitX = chartArea.width / (x.max - x.min);\n    const pxPerUnitY = chartArea.height / (y.max - y.min);\n    const pxPerUnit = Math.min(pxPerUnitX, pxPerUnitY);\n    const halfX = chartArea.width / pxPerUnit / 2;\n    const halfY = chartArea.height / pxPerUnit / 2;\n\n    x.options.min = xCenter - halfX;\n    x.options.max = xCenter + halfX;\n    y.options.min = yCenter - halfY;\n    y.options.max = yCenter + halfY;\n    chart.$argandAspectFixed = true;\n    chart.update(\"none\");\n  },\n};\n\n// --- Chart --------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Cube roots of unity\",\n        data: rootsOfUnity,\n        backgroundColor: t.palette[0],\n        borderColor: t.palette[0],\n        pointStyle: \"circle\",\n        pointRadius: 9,\n        pointHoverRadius: 9,\n      },\n      {\n        label: \"Arbitrary points & sum\",\n        data: arbitraryPoints,\n        backgroundColor: t.palette[1],\n        borderColor: t.palette[1],\n        pointStyle: \"rectRot\",\n        pointRadius: 9,\n        pointHoverRadius: 9,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 24 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"scatter-complex-plane · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n        padding: { bottom: 20 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: { color: t.ink, font: { size: 16 }, usePointStyle: true, boxWidth: 10, padding: 20 },\n      },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        position: { y: 0 },\n        min: -AXIS_LIMIT,\n        max: AXIS_LIMIT,\n        ticks: { stepSize: 1, includeBounds: false, color: t.inkSoft, font: { size: 14 } },\n        grid: { color: softGrid },\n        border: { color: t.inkSoft, width: 1.5 },\n      },\n      y: {\n        type: \"linear\",\n        position: { x: 0 },\n        min: -AXIS_LIMIT,\n        max: AXIS_LIMIT,\n        // The x-axis already renders the \"0\" tick at the origin; suppress\n        // the y-axis' own \"0\" so the two labels don't land on the same pixel.\n        ticks: {\n          stepSize: 1,\n          includeBounds: false,\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => (value === 0 ? \"\" : value),\n        },\n        grid: { color: softGrid },\n        border: { color: t.inkSoft, width: 1.5 },\n      },\n    },\n  },\n  plugins: [argandFurniture],\n});\n"}