{"spec_id":"campbell-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// campbell-basic: Campbell Diagram\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-08-24\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Natural frequency curves are modeled as linear functions of speed, matching\n// the mild stiffening/softening gyroscopic behavior the spec calls for.\nconst SPEED_MAX = 6000;\nconst SPEED_STEP = 100;\nconst FREQ_MAX = 150;\n\nconst speeds = [];\nfor (let speed = 0; speed <= SPEED_MAX; speed += SPEED_STEP) speeds.push(speed);\n\nconst modes = [\n  { name: \"1st Bending\", a: 25, b: 0.0012, color: t.palette[0] },\n  { name: \"2nd Bending\", a: 62, b: -0.0015, color: t.palette[1] },\n  { name: \"1st Torsional\", a: 85, b: 0.0002, color: t.palette[2] },\n  { name: \"Axial\", a: 112, b: 0.0008, color: t.palette[3] },\n];\n\nconst engineOrders = [\n  { order: 1, dash: [3, 3] },\n  { order: 2, dash: [10, 4] },\n  { order: 3, dash: [14, 4, 2, 4] },\n];\n\n// Critical speeds: exact intersection of an engine-order line\n// (freq = order/60 * speed) with a mode's linear frequency curve\n// (freq = a + b * speed), solved algebraically.\nconst criticalSpeeds = [];\nengineOrders.forEach(({ order }) => {\n  modes.forEach((mode) => {\n    const slope = order / 60;\n    if (slope === mode.b) return;\n    const speed = mode.a / (slope - mode.b);\n    if (speed > 0 && speed <= SPEED_MAX) {\n      criticalSpeeds.push({ x: speed, y: slope * speed });\n    }\n  });\n});\ncriticalSpeeds.sort((p1, p2) => p1.x - p2.x);\nconst shownCriticalSpeeds = criticalSpeeds.slice(0, 10);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Datasets ------------------------------------------------------------\nconst modeDatasets = modes.map((mode) => ({\n  label: mode.name,\n  data: speeds.map((speed) => ({ x: speed, y: mode.a + mode.b * speed })),\n  borderColor: mode.color,\n  backgroundColor: mode.color,\n  borderWidth: 3.5,\n  pointRadius: 0,\n  tension: 0,\n}));\n\nconst orderDatasets = engineOrders.map(({ order, dash }) => ({\n  label: `${order}x Engine Order`,\n  data: [\n    { x: 0, y: 0 },\n    { x: SPEED_MAX, y: (order * SPEED_MAX) / 60 },\n  ],\n  borderColor: t.ink,\n  backgroundColor: t.ink,\n  borderWidth: 2,\n  borderDash: dash,\n  pointRadius: 0,\n  tension: 0,\n}));\n\nconst criticalDataset = {\n  label: \"Critical Speeds\",\n  data: shownCriticalSpeeds,\n  showLine: false,\n  pointStyle: \"rectRot\",\n  pointRadius: 9,\n  pointBorderWidth: 2,\n  backgroundColor: t.palette[4],\n  borderColor: t.pageBg,\n};\n\n// Draws \"1x\"/\"2x\"/\"3x\" labels directly on the engine-order lines, at the\n// point where each line either reaches the right edge or exits the top of\n// the plot area (order lines are steep enough that most exit through the\n// top well before SPEED_MAX).\nconst orderLabelPlugin = {\n  id: \"orderLabelPlugin\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    ctx.save();\n    ctx.font = \"600 13px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"right\";\n    ctx.textBaseline = \"bottom\";\n    engineOrders.forEach(({ order }) => {\n      const endX = Math.min(SPEED_MAX, (FREQ_MAX * 60) / order);\n      const endY = (order / 60) * endX;\n      const px = Math.min(scales.x.getPixelForValue(endX) - 6, chartArea.right - 4);\n      const py = Math.max(scales.y.getPixelForValue(endY) - 4, chartArea.top + 12);\n      ctx.fillText(`${order}x`, px, py);\n    });\n    ctx.restore();\n  },\n};\n\n// --- Chart -----------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"line\",\n  data: { datasets: [...modeDatasets, ...orderDatasets, criticalDataset] },\n  plugins: [orderLabelPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"campbell-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: {\n          color: t.ink,\n          font: { size: 14 },\n          usePointStyle: true,\n          boxWidth: 20,\n          padding: 16,\n          // Default generateLabels renders every series as a circle, so the\n          // three engine-order lines are indistinguishable in the legend\n          // even though they use distinct dash patterns on the plot. Swap\n          // those entries to the \"line\" point style, which respects the\n          // dataset's borderDash when stroked.\n          generateLabels(chart) {\n            const items = Chart.defaults.plugins.legend.labels.generateLabels.call(this, chart);\n            items.forEach((item) => {\n              const dataset = chart.data.datasets[item.datasetIndex];\n              if (dataset.borderDash) item.pointStyle = \"line\";\n            });\n            return items;\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: 0,\n        max: SPEED_MAX,\n        title: { display: true, text: \"Rotational Speed (RPM)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 1000 },\n        grid: { color: t.grid },\n      },\n      y: {\n        min: 0,\n        max: FREQ_MAX,\n        title: { display: true, text: \"Natural Frequency (Hz)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 25 },\n        grid: { color: t.grid },\n      },\n    },\n  },\n});\n"}