{"spec_id":"candlestick-volume","library":"d3","language":"javascript","code":"// anyplot.ai\n// candlestick-volume: Stock Candlestick Chart with Volume\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: 60 trading days of OHLC + volume for a fictional stock ----------\n// Fixed-seed LCG — the browser has no seeded RNG.\nfunction lcg(seed) {\n  let state = seed >>> 0;\n  return function () {\n    state = (1103515245 * state + 12345) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst data = [];\nlet price = 142;\nconst cursor = new Date(2024, 0, 1);\nwhile (data.length < 60) {\n  cursor.setDate(cursor.getDate() + 1);\n  const weekday = cursor.getDay();\n  if (weekday === 0 || weekday === 6) continue;\n\n  const changePct = (rand() - 0.48) * 0.05;\n  const open = price;\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 volatility = Math.abs(changePct);\n  const volume = Math.round(1.4e6 * (1 + volatility * 26) * (0.65 + rand() * 0.6));\n\n  data.push({ date: new Date(cursor), open, high, low, close, volume });\n  price = close;\n}\n\n// --- Layout: price pane (~72%) + volume pane (~28%), shared x-axis ---------\nconst margin = { top: 112, right: 90, bottom: 64, left: 116 };\nconst paneGap = 36;\nconst plotWidth = width - margin.left - margin.right;\nconst plotHeight = height - margin.top - margin.bottom;\nconst priceHeight = Math.round(plotHeight * 0.72);\nconst volumeHeight = plotHeight - priceHeight - paneGap;\n\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst defs = svg.append(\"defs\");\n\nconst x = d3.scaleBand().domain(d3.range(data.length)).range([0, plotWidth]).padding(0.35);\nconst yPrice = d3\n  .scaleLinear()\n  .domain([d3.min(data, (d) => d.low) * 0.99, d3.max(data, (d) => d.high) * 1.01])\n  .range([priceHeight, 0]);\nconst yVolume = d3\n  .scaleLinear()\n  .domain([0, d3.max(data, (d) => d.volume) * 1.15])\n  .range([volumeHeight, 0]);\n\nconst UP = t.palette[0]; // brand green — profit/up\nconst DOWN = t.palette[4]; // semantic red anchor — loss/down\nconst colorFor = (d) => (d.close >= d.open ? UP : DOWN);\nconst isUp = (d) => d.close >= d.open;\n\n// CVD-safe secondary cues (VQ-04): up candles render hollow, down candles solid;\n// down-volume bars carry a diagonal hatch — shape distinguishes up/down without hue.\nconst hatch = defs\n  .append(\"pattern\")\n  .attr(\"id\", \"down-volume-hatch\")\n  .attr(\"width\", 6)\n  .attr(\"height\", 6)\n  .attr(\"patternUnits\", \"userSpaceOnUse\")\n  .attr(\"patternTransform\", \"rotate(45)\");\nhatch.append(\"rect\").attr(\"width\", 6).attr(\"height\", 6).attr(\"fill\", DOWN);\nhatch.append(\"line\").attr(\"x1\", 0).attr(\"y1\", 0).attr(\"x2\", 0).attr(\"y2\", 6).attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 2);\n\nconst tooltipShadow = defs\n  .append(\"filter\")\n  .attr(\"id\", \"tooltip-shadow\")\n  .attr(\"x\", \"-30%\")\n  .attr(\"y\", \"-30%\")\n  .attr(\"width\", \"160%\")\n  .attr(\"height\", \"160%\");\ntooltipShadow.append(\"feDropShadow\").attr(\"dx\", 0).attr(\"dy\", 2).attr(\"stdDeviation\", 3).attr(\"flood-opacity\", 0.28);\n\n// --- Vertical gridlines spanning both panes (aligned by construction) ------\nconst tickStep = Math.max(1, Math.round(data.length / 8));\nconst tickIndices = data.map((_, i) => i).filter((i) => i % tickStep === 0);\n\nconst gridLayer = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\ngridLayer\n  .selectAll(\"line.vgrid\")\n  .data(tickIndices)\n  .join(\"line\")\n  .attr(\"class\", \"vgrid\")\n  .attr(\"x1\", (i) => x(i) + x.bandwidth() / 2)\n  .attr(\"x2\", (i) => x(i) + x.bandwidth() / 2)\n  .attr(\"y1\", 0)\n  .attr(\"y2\", priceHeight + paneGap + volumeHeight)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// --- Price pane --------------------------------------------------------------\nconst gPrice = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\ngPrice\n  .append(\"g\")\n  .call(d3.axisLeft(yPrice).ticks(6).tickFormat(d3.format(\"$,.0f\")).tickSize(-plotWidth))\n  .call((g) => g.select(\".domain\").remove())\n  .call((g) => g.selectAll(\".tick line\").attr(\"stroke\", t.grid))\n  .call((g) => g.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\").attr(\"x\", -10));\n\ngPrice\n  .append(\"g\")\n  .call(d3.axisLeft(yPrice).ticks(6).tickFormat(d3.format(\"$,.0f\")))\n  .call((g) => g.select(\".domain\").attr(\"stroke\", t.inkSoft))\n  .call((g) => g.selectAll(\".tick line\").remove())\n  .call((g) => g.selectAll(\".tick text\").remove());\n\nconst candles = gPrice.selectAll(\"g.candle\").data(data).join(\"g\").attr(\"class\", \"candle\");\n\ncandles\n  .append(\"line\")\n  .attr(\"x1\", (d, i) => x(i) + x.bandwidth() / 2)\n  .attr(\"x2\", (d, i) => x(i) + x.bandwidth() / 2)\n  .attr(\"y1\", (d) => yPrice(d.high))\n  .attr(\"y2\", (d) => yPrice(d.low))\n  .attr(\"stroke\", colorFor)\n  .attr(\"stroke-width\", 1.6);\n\ncandles\n  .append(\"rect\")\n  .attr(\"x\", (d, i) => x(i))\n  .attr(\"width\", x.bandwidth())\n  .attr(\"y\", (d) => yPrice(Math.max(d.open, d.close)))\n  .attr(\"height\", (d) => Math.max(1.5, Math.abs(yPrice(d.open) - yPrice(d.close))))\n  .attr(\"fill\", (d) => (isUp(d) ? t.pageBg : DOWN))\n  .attr(\"stroke\", colorFor)\n  .attr(\"stroke-width\", (d) => (isUp(d) ? 2 : 0));\n\ngPrice\n  .append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -priceHeight / 2)\n  .attr(\"y\", -92)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Price (USD)\");\n\n// Legend — up / down bias, shape mirrors the hollow/solid candle cue\nconst legend = gPrice.append(\"g\").attr(\"transform\", `translate(${plotWidth - 220},-58)`);\nconst legendItems = [\n  { label: \"Up close\", color: UP, up: true },\n  { label: \"Down close\", color: DOWN, up: false },\n];\nlegendItems.forEach((item, i) => {\n  const row = legend.append(\"g\").attr(\"transform\", `translate(${i * 130},0)`);\n  row\n    .append(\"rect\")\n    .attr(\"width\", 16)\n    .attr(\"height\", 16)\n    .attr(\"y\", -12)\n    .attr(\"rx\", 3)\n    .attr(\"fill\", item.up ? t.pageBg : item.color)\n    .attr(\"stroke\", item.color)\n    .attr(\"stroke-width\", item.up ? 2 : 0);\n  row\n    .append(\"text\")\n    .attr(\"x\", 24)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"14px\")\n    .text(item.label);\n});\n\n// --- Volume pane ---------------------------------------------------------\nconst gVolume = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top + priceHeight + paneGap})`);\n\ngVolume\n  .append(\"g\")\n  .call(d3.axisLeft(yVolume).ticks(3).tickFormat(d3.format(\".2s\")).tickSize(-plotWidth))\n  .call((g) => g.select(\".domain\").remove())\n  .call((g) => g.selectAll(\".tick line\").attr(\"stroke\", t.grid))\n  .call((g) => g.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\").attr(\"x\", -10));\n\ngVolume\n  .append(\"g\")\n  .call(d3.axisLeft(yVolume).ticks(3).tickFormat(d3.format(\".2s\")))\n  .call((g) => g.select(\".domain\").attr(\"stroke\", t.inkSoft))\n  .call((g) => g.selectAll(\".tick line\").remove())\n  .call((g) => g.selectAll(\".tick text\").remove());\n\ngVolume\n  .selectAll(\"rect.volume\")\n  .data(data)\n  .join(\"rect\")\n  .attr(\"class\", \"volume\")\n  .attr(\"x\", (d, i) => x(i))\n  .attr(\"width\", x.bandwidth())\n  .attr(\"y\", (d) => yVolume(d.volume))\n  .attr(\"height\", (d) => volumeHeight - yVolume(d.volume))\n  .attr(\"fill\", (d) => (isUp(d) ? UP : \"url(#down-volume-hatch)\"))\n  .attr(\"opacity\", 0.85);\n\n// --- Storytelling emphasis: call out the peak-volume trading day -----------\nlet peakIdx = 0;\ndata.forEach((d, i) => {\n  if (d.volume > data[peakIdx].volume) peakIdx = i;\n});\nconst peakX = x(peakIdx) + x.bandwidth() / 2;\nconst peakY = yVolume(data[peakIdx].volume);\nconst peakAnchor = peakIdx < data.length / 2 ? \"start\" : \"end\";\n\nconst annotation = gVolume.append(\"g\").attr(\"class\", \"annotation\");\nannotation\n  .append(\"line\")\n  .attr(\"x1\", peakX)\n  .attr(\"x2\", peakX)\n  .attr(\"y1\", peakY - 26)\n  .attr(\"y2\", peakY - 6)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.2);\nannotation\n  .append(\"text\")\n  .attr(\"x\", peakX + (peakAnchor === \"start\" ? 8 : -8))\n  .attr(\"y\", peakY - 18)\n  .attr(\"text-anchor\", peakAnchor)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .style(\"font-style\", \"italic\")\n  .text(\"Peak volume\");\n\ngVolume\n  .append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -volumeHeight / 2)\n  .attr(\"y\", -92)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Volume\");\n\n// Shared x-axis, drawn once beneath the volume pane\nconst xAxis = gVolume\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${volumeHeight})`)\n  .call(d3.axisBottom(x).tickValues(tickIndices).tickFormat((i) => d3.timeFormat(\"%b %d\")(data[i].date)));\nxAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\nxAxis.selectAll(\".tick line\").attr(\"stroke\", t.inkSoft);\nxAxis.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n\n// --- Crosshair spanning both panes, driven by real pointer events ----------\nconst crosshairLayer = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\nconst crosshairLine = crosshairLayer\n  .append(\"line\")\n  .attr(\"y1\", 0)\n  .attr(\"y2\", priceHeight + paneGap + volumeHeight)\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 1)\n  .attr(\"opacity\", 0);\n\nconst tooltip = crosshairLayer.append(\"g\").attr(\"opacity\", 0);\nconst tooltipBg = tooltip\n  .append(\"rect\")\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"rx\", 8)\n  .attr(\"filter\", \"url(#tooltip-shadow)\");\nconst tooltipText = tooltip\n  .append(\"text\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-family\", \"monospace\");\n\nfunction updateTooltip(i) {\n  const d = data[i];\n  const cx = x(i) + x.bandwidth() / 2;\n  crosshairLine.attr(\"x1\", cx).attr(\"x2\", cx);\n\n  tooltipText.selectAll(\"tspan\").remove();\n  const fmt = d3.format(\"$,.2f\");\n  const arrow = isUp(d) ? \"▲\" : \"▼\"; // secondary up/down cue, redundant with hue\n  const lines = [\n    d3.timeFormat(\"%b %d, %Y\")(d.date),\n    `O ${fmt(d.open)}  H ${fmt(d.high)}`,\n    `L ${fmt(d.low)}  C ${fmt(d.close)} ${arrow}`,\n    `Vol ${d3.format(\",\")(d.volume)}`,\n  ];\n  lines.forEach((line, li) => {\n    const tspan = tooltipText.append(\"tspan\").attr(\"x\", 14).attr(\"y\", 20 + li * 19).text(line);\n    if (li === 2) tspan.attr(\"fill\", colorFor(d));\n  });\n\n  const boxWidth = 220;\n  const boxHeight = lines.length * 19 + 16;\n  const tooltipX = Math.min(cx + 16, plotWidth - boxWidth);\n  tooltip.attr(\"transform\", `translate(${Math.max(0, tooltipX)},4)`);\n  tooltipBg.attr(\"width\", boxWidth).attr(\"height\", boxHeight);\n}\n\nconst overlay = crosshairLayer\n  .append(\"rect\")\n  .attr(\"width\", plotWidth)\n  .attr(\"height\", priceHeight + paneGap + volumeHeight)\n  .attr(\"fill\", \"transparent\")\n  .style(\"cursor\", \"crosshair\");\n\noverlay\n  .on(\"pointerenter\", () => {\n    crosshairLine.attr(\"opacity\", 1);\n    tooltip.attr(\"opacity\", 1);\n  })\n  .on(\"pointerleave\", () => {\n    crosshairLine.attr(\"opacity\", 0);\n    tooltip.attr(\"opacity\", 0);\n  })\n  .on(\"pointermove\", (event) => {\n    const [mx] = d3.pointer(event, overlay.node());\n    const i = Math.max(0, Math.min(data.length - 1, Math.floor(mx / x.step())));\n    updateTooltip(i);\n  });\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(\"NovaTech 2024 · candlestick-volume · javascript · d3 · anyplot.ai\");\n"}