{"spec_id":"candlestick-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// candlestick-basic: Basic Candlestick Chart\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 96/100 | Created: 2026-08-24\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Tiny fixed-seed LCG — the browser has no seeded RNG.\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst sessionCount = 26;\nconst labels = [];\nconst opens = [];\nconst highs = [];\nconst lows = [];\nconst closes = [];\nconst barColors = [];\nconst barBorderColors = [];\nconst barBorderWidths = [];\n\nlet price = 148;\nconst date = new Date(Date.UTC(2024, 0, 2));\nfor (let i = 0; i < sessionCount; i++) {\n  while (date.getUTCDay() === 0 || date.getUTCDay() === 6) {\n    date.setUTCDate(date.getUTCDate() + 1);\n  }\n\n  const open = price;\n  const changePct = (rand() - 0.5) * 0.05;\n  const close = open * (1 + changePct);\n  const high = Math.max(open, close) * (1 + rand() * 0.012);\n  const low = Math.min(open, close) * (1 - rand() * 0.012);\n  const isBearish = close < open;\n\n  labels.push(date.toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" }));\n  opens.push(open);\n  highs.push(high);\n  lows.push(low);\n  closes.push(close);\n  barColors.push(isBearish ? t.palette[4] : t.palette[0]);\n  // Redundant shape cue (VQ-04): bearish bodies get an ink outline so\n  // direction doesn't rely on hue alone for colorblind viewers.\n  barBorderColors.push(isBearish ? t.ink : \"transparent\");\n  barBorderWidths.push(isBearish ? 1 : 0);\n\n  price = close;\n  date.setUTCDate(date.getUTCDate() + 1);\n}\n\nconst priceMin = Math.min(...lows);\nconst priceMax = Math.max(...highs);\nconst pricePad = (priceMax - priceMin) * 0.15;\n\n// --- Storytelling: highlight the single widest daily range -----------------\nlet widestIdx = 0;\nlet widestRange = 0;\nfor (let i = 0; i < sessionCount; i++) {\n  const range = highs[i] - lows[i];\n  if (range > widestRange) {\n    widestRange = range;\n    widestIdx = i;\n  }\n}\n\n// --- 5-session moving average overlay ---------------------------------------\nconst maWindow = 5;\nconst movingAvg = closes.map((_, i) => {\n  if (i < maWindow - 1) return null;\n  let sum = 0;\n  for (let j = i - maWindow + 1; j <= i; j++) sum += closes[j];\n  return sum / maWindow;\n});\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Annotation plugin -------------------------------------------------------\n// Draws a muted vertical band + amber marker over the widest single-day range,\n// using only core Chart.js plugin hooks (no external plugin package).\nconst widestRangePlugin = {\n  id: \"widestRangeHighlight\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const centerX = scales.x.getPixelForValue(widestIdx);\n    const bandHalfWidth = 20;\n    ctx.save();\n    ctx.globalAlpha = 0.08;\n    ctx.fillStyle = t.ink;\n    ctx.fillRect(centerX - bandHalfWidth, chartArea.top, bandHalfWidth * 2, chartArea.bottom - chartArea.top);\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const centerX = scales.x.getPixelForValue(widestIdx);\n    const topY = scales.y.getPixelForValue(highs[widestIdx]);\n    const markerY = Math.max(topY - 14, chartArea.top + 14);\n\n    let align = \"center\";\n    let textX = centerX;\n    if (centerX < chartArea.left + 80) {\n      align = \"left\";\n      textX = chartArea.left + 4;\n    } else if (centerX > chartArea.right - 80) {\n      align = \"right\";\n      textX = chartArea.right - 4;\n    }\n\n    ctx.save();\n    ctx.strokeStyle = t.amber;\n    ctx.lineWidth = 1.5;\n    ctx.beginPath();\n    ctx.moveTo(centerX, markerY + 6);\n    ctx.lineTo(centerX, topY - 2);\n    ctx.stroke();\n\n    ctx.fillStyle = t.amber;\n    ctx.beginPath();\n    ctx.arc(centerX, markerY, 4, 0, Math.PI * 2);\n    ctx.fill();\n\n    ctx.fillStyle = t.ink;\n    ctx.font = \"13px sans-serif\";\n    ctx.textAlign = align;\n    ctx.fillText(`Widest range: $${widestRange.toFixed(2)}`, textX, markerY - 10);\n    ctx.restore();\n  },\n};\n\n// --- Chart ---------------------------------------------------------------\n// Core Chart.js has no dedicated candlestick type, so the candle is built from\n// two overlaid floating-bar datasets on the same category axis: a thin \"wick\"\n// (low -> high) and a wider \"body\" (open -> close), both centered on the same\n// tick via grouped: false. This uses only core Chart.js bar-chart features.\nnew Chart(canvas, {\n  type: \"bar\",\n  data: {\n    labels,\n    datasets: [\n      {\n        label: \"Wick\",\n        data: lows.map((low, i) => [low, highs[i]]),\n        backgroundColor: barColors,\n        borderWidth: 0,\n        barThickness: 3,\n        grouped: false,\n      },\n      {\n        label: \"Body\",\n        data: opens.map((open, i) => [open, closes[i]]),\n        backgroundColor: barColors,\n        borderColor: barBorderColors,\n        borderWidth: barBorderWidths,\n        barThickness: 18,\n        grouped: false,\n      },\n      {\n        type: \"line\",\n        label: \"5-session avg\",\n        data: movingAvg,\n        borderColor: t.inkSoft,\n        borderWidth: 2,\n        borderDash: [6, 3],\n        pointRadius: 0,\n        spanGaps: false,\n      },\n    ],\n  },\n  plugins: [widestRangePlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"candlestick-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: {\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          generateLabels: () => [\n            { text: \"Bullish (close ≥ open)\", fillStyle: t.palette[0], strokeStyle: t.palette[0], lineWidth: 0 },\n            { text: \"Bearish (close < open)\", fillStyle: t.palette[4], strokeStyle: t.palette[4], lineWidth: 0 },\n            { text: \"5-session avg\", fillStyle: t.inkSoft, strokeStyle: t.inkSoft, lineWidth: 0 },\n          ],\n        },\n        onClick: () => {},\n      },\n      tooltip: {\n        mode: \"index\",\n        intersect: false,\n        filter: (item) => item.dataset.label === \"Body\",\n        callbacks: {\n          label: (item) => {\n            const i = item.dataIndex;\n            return `O: $${opens[i].toFixed(2)}  H: $${highs[i].toFixed(2)}  L: $${lows[i].toFixed(2)}  C: $${closes[i].toFixed(2)}`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          maxRotation: 45,\n          minRotation: 45,\n          autoSkip: true,\n          maxTicksLimit: 12,\n        },\n        grid: { display: false },\n        title: { display: true, text: \"Date\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        min: priceMin - pricePad,\n        max: priceMax + pricePad,\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => \"$\" + value.toFixed(0),\n        },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Price (USD)\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n});\n"}