{"spec_id":"candlestick-volume","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// candlestick-volume: Stock Candlestick Chart with Volume\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst upColor = t.palette[0]; // #009E73 brand green — bullish (close >= open)\nconst downColor = t.palette[4]; // #AE3030 matte red — bearish (close < open), finance semantic exception\n\n// --- Data: 60 trading days of a synthetic ticker, deterministic LCG walk ---\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 MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst NUM_DAYS = 60;\nconst dates = [];\nconst cursor = new Date(Date.UTC(2025, 0, 2));\nwhile (dates.length < NUM_DAYS) {\n  const weekday = cursor.getUTCDay();\n  if (weekday !== 0 && weekday !== 6) dates.push(new Date(cursor));\n  cursor.setUTCDate(cursor.getUTCDate() + 1);\n}\nconst dateLabels = dates.map((d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`);\n\nconst rows = [];\nlet prevClose = 182.5;\nfor (let i = 0; i < NUM_DAYS; i++) {\n  const open = prevClose + (rand() - 0.5) * 1.6;\n  const drift = (rand() - 0.47) * 4.2;\n  const close = open + drift;\n  const high = Math.max(open, close) + rand() * 1.9;\n  const low = Math.min(open, close) - rand() * 1.9;\n  const volume = Math.round(1.1e6 + Math.abs(drift) * 3.6e5 + rand() * 5.5e5);\n  rows.push({\n    open: Math.round(open * 100) / 100,\n    high: Math.round(high * 100) / 100,\n    low: Math.round(low * 100) / 100,\n    close: Math.round(close * 100) / 100,\n    volume,\n  });\n  prevClose = close;\n}\nconst barColors = rows.map((r) => (r.close >= r.open ? upColor : downColor));\n\n// Zoom the price axis to the data range instead of $0 — standard OHLC\n// convention, and it keeps the candle bodies/wicks legible on a 60-day span.\nconst pricePad = (Math.max(...rows.map((r) => r.high)) - Math.min(...rows.map((r) => r.low))) * 0.12;\nconst priceMin = Math.floor(Math.min(...rows.map((r) => r.low)) - pricePad);\nconst priceMax = Math.ceil(Math.max(...rows.map((r) => r.high)) + pricePad);\n\n// Both panes share the same category count but only every Nth label is\n// shown — pruning identical tick indices on both x-scales keeps gridlines\n// pixel-aligned across the two panes (spec: \"grid lines ... aligned\").\nconst TICK_STEP = 8;\nfunction pruneTicks(scale) {\n  scale.ticks = scale.ticks.filter((_, i) => i % TICK_STEP === 0);\n}\n\n// --- Mount: two stacked panes sharing a category axis ----------------------\nconst wrapper = document.createElement(\"div\");\nwrapper.style.display = \"flex\";\nwrapper.style.flexDirection = \"column\";\nwrapper.style.width = \"100%\";\nwrapper.style.height = \"100%\";\ndocument.getElementById(\"container\").appendChild(wrapper);\n\nconst pricePane = document.createElement(\"div\");\npricePane.style.flex = \"0 0 72%\";\npricePane.style.minHeight = \"0\";\nwrapper.appendChild(pricePane);\n\nconst volumePane = document.createElement(\"div\");\nvolumePane.style.flex = \"0 0 28%\";\nvolumePane.style.minHeight = \"0\";\nwrapper.appendChild(volumePane);\n\nconst priceCanvas = document.createElement(\"canvas\");\npricePane.appendChild(priceCanvas);\nconst volumeCanvas = document.createElement(\"canvas\");\nvolumePane.appendChild(volumeCanvas);\n\n// --- Title, sized to the mandated title-length formula ---------------------\nconst TITLE = \"TechNova Inc. (TCNV) · candlestick-volume · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = TITLE.length > 67 ? Math.round(22 * (67 / TITLE.length)) : 22;\n\n// --- Synced crosshair: a local plugin, no chartjs-chart-* package ----------\n// Only draws once a real mousemove event fires on either pane — never baked\n// into the static screenshot, so the light/dark PNGs stay crosshair-free.\nconst sharedHover = { index: null };\nlet priceChart;\nlet volumeChart;\n\nconst syncCrosshair = {\n  id: \"syncCrosshair\",\n  afterEvent(chart, args) {\n    const event = args.event;\n    if (event.type === \"mousemove\") {\n      const points = chart.getElementsAtEventForMode(event, \"index\", { intersect: false }, true);\n      if (points.length) {\n        sharedHover.index = points[0].index;\n        priceChart.update(\"none\");\n        volumeChart.update(\"none\");\n      }\n    } else if (event.type === \"mouseout\") {\n      sharedHover.index = null;\n      priceChart.update(\"none\");\n      volumeChart.update(\"none\");\n    }\n  },\n  afterDraw(chart) {\n    if (sharedHover.index === null) return;\n    const el = chart.getDatasetMeta(0).data[sharedHover.index];\n    if (!el) return;\n    const { ctx, chartArea } = chart;\n    ctx.save();\n    ctx.strokeStyle = t.ink;\n    ctx.globalAlpha = 0.35;\n    ctx.lineWidth = 1;\n    ctx.setLineDash([4, 4]);\n    ctx.beginPath();\n    ctx.moveTo(el.x, chartArea.top);\n    ctx.lineTo(el.x, chartArea.bottom);\n    ctx.stroke();\n    ctx.restore();\n  },\n};\nChart.register(syncCrosshair);\n\n// --- Price pane: candlestick built from two overlaid floating-bar datasets -\npriceChart = new Chart(priceCanvas, {\n  type: \"bar\",\n  data: {\n    labels: dateLabels,\n    datasets: [\n      {\n        label: \"High-Low\",\n        data: rows.map((r) => [r.low, r.high]),\n        backgroundColor: barColors,\n        barThickness: 2,\n        order: 1,\n      },\n      {\n        label: \"Open-Close\",\n        data: rows.map((r) => [Math.min(r.open, r.close), Math.max(r.open, r.close)]),\n        backgroundColor: barColors,\n        barThickness: 13,\n        order: 2,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    interaction: { mode: \"index\", intersect: false },\n    plugins: {\n      title: { display: true, text: TITLE, color: t.ink, font: { size: titleFontSize, weight: \"500\" } },\n      legend: {\n        display: true,\n        position: \"top\",\n        align: \"end\",\n        onClick: () => {},\n        labels: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          boxWidth: 14,\n          generateLabels: () => [\n            { text: \"Bullish (close ≥ open)\", fillStyle: upColor, strokeStyle: upColor },\n            { text: \"Bearish (close < open)\", fillStyle: downColor, strokeStyle: downColor },\n          ],\n        },\n      },\n      tooltip: {\n        backgroundColor: t.elevatedBg,\n        titleColor: t.ink,\n        bodyColor: t.inkSoft,\n        borderColor: t.grid,\n        borderWidth: 1,\n        callbacks: {\n          title: (items) => dateLabels[items[0].dataIndex],\n          label: (item) => {\n            const r = rows[item.dataIndex];\n            return [`Open ${r.open.toFixed(2)}  High ${r.high.toFixed(2)}`, `Low ${r.low.toFixed(2)}  Close ${r.close.toFixed(2)}`];\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        grouped: false,\n        ticks: { display: false },\n        grid: { color: t.grid },\n        afterBuildTicks: pruneTicks,\n      },\n      y: {\n        min: priceMin,\n        max: priceMax,\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => `$${v}` },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Price (USD)\", color: t.ink, font: { size: 16 } },\n        afterFit: (scale) => {\n          scale.width = 76;\n        },\n      },\n    },\n  },\n  plugins: [syncCrosshair],\n});\n\n// --- Volume pane: bars colored by the same day's up/down direction ---------\nvolumeChart = new Chart(volumeCanvas, {\n  type: \"bar\",\n  data: {\n    labels: dateLabels,\n    datasets: [\n      {\n        label: \"Volume\",\n        data: rows.map((r) => r.volume),\n        backgroundColor: barColors,\n        barPercentage: 0.7,\n        categoryPercentage: 0.85,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    interaction: { mode: \"index\", intersect: false },\n    plugins: {\n      title: { display: false },\n      legend: { display: false },\n      tooltip: {\n        backgroundColor: t.elevatedBg,\n        titleColor: t.ink,\n        bodyColor: t.inkSoft,\n        borderColor: t.grid,\n        borderWidth: 1,\n        callbacks: {\n          label: (item) => `Volume ${(item.raw / 1e6).toFixed(2)}M`,\n        },\n      },\n    },\n    scales: {\n      x: {\n        grouped: false,\n        ticks: { color: t.inkSoft, font: { size: 13 }, maxRotation: 0 },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Trading Date\", color: t.ink, font: { size: 16 } },\n        afterBuildTicks: pruneTicks,\n      },\n      y: {\n        ticks: { color: t.inkSoft, font: { size: 13 }, callback: (v) => `${(v / 1e6).toFixed(1)}M`, maxTicksLimit: 4 },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Volume\", color: t.ink, font: { size: 14 } },\n        afterFit: (scale) => {\n          scale.width = 76;\n        },\n      },\n    },\n  },\n  plugins: [syncCrosshair],\n});\n"}