{"spec_id":"stock-event-flags","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// stock-event-flags: Stock Chart with Event Flags\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME === \"dark\" ? \"dark\" : \"light\";\nconst INK_MUTED = THEME === \"dark\" ? \"#A8A79F\" : \"#6B6A63\"; // tertiary text (theme-adaptive, not in token set)\n\n// --- Data (in-memory, deterministic) ----------------------------------------\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1103515245 + 12345) % 2147483648;\n    return state / 2147483648;\n  };\n}\nconst rand = lcg(42);\n\nconst TRADING_DAYS = 220;\nconst START_DATE = new Date(2024, 0, 2); // Jan 2, 2024\n\nconst dates = [];\nconst cursor = new Date(START_DATE);\nwhile (dates.length < TRADING_DAYS) {\n  const weekday = cursor.getDay();\n  if (weekday !== 0 && weekday !== 6) dates.push(new Date(cursor));\n  cursor.setDate(cursor.getDate() + 1);\n}\nconst labels = dates.map((d) => d.toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" }));\n\nconst SPLIT_INDEX = 112;\nlet price = 148;\nconst closePrices = [];\nfor (let i = 0; i < TRADING_DAYS; i++) {\n  const shock = (rand() - 0.5) * 4.4;\n  price = Math.max(24, price + shock + 0.06);\n  closePrices.push(price);\n}\n// The 2-for-1 split roughly halves the nominal share price from that day on.\nfor (let i = SPLIT_INDEX; i < TRADING_DAYS; i++) closePrices[i] *= 0.5;\nconst roundedPrices = closePrices.map((p) => Number(p.toFixed(2)));\n\nconst EVENT_TYPES = {\n  earnings: { label: \"Earnings\", color: t.palette[1] },\n  dividend: { label: \"Dividend\", color: t.palette[2] },\n  split: { label: \"Stock Split\", color: t.palette[3] },\n  news: { label: \"News\", color: t.palette[5] },\n};\n\nconst events = [\n  { index: 18, type: \"earnings\", label: \"Q4 Earnings\" },\n  { index: 42, type: \"dividend\", label: \"Div $0.24\" },\n  { index: 70, type: \"news\", label: \"Product Launch\" },\n  { index: 90, type: \"earnings\", label: \"Q1 Earnings\" },\n  { index: SPLIT_INDEX, type: \"split\", label: \"2-for-1 Split\" },\n  { index: 140, type: \"dividend\", label: \"Div $0.25\" },\n  { index: 165, type: \"earnings\", label: \"Q2 Earnings\" },\n  { index: 195, type: \"news\", label: \"Analyst Upgrade\" },\n];\nconst BLACKOUT = { startIndex: 12, endIndex: 24 }; // pre-earnings blackout window\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Axis headroom for flags (reserved space above/below the price line) -----\nconst minPrice = Math.min(...roundedPrices);\nconst maxPrice = Math.max(...roundedPrices);\nconst priceRange = maxPrice - minPrice;\nconst yMin = minPrice - priceRange * 0.3;\nconst yMax = maxPrice + priceRange * 0.3;\n\n// Sparse series carrying only the event points, at their exact price — an\n// invisible dataset so the native Chart.js tooltip can surface event details\n// on hover in the interactive HTML view (spec: \"hover ... should reveal full\n// event details\"). Static PNG is unaffected since nothing is hovered mid-render.\nconst eventPoints = new Array(TRADING_DAYS).fill(null);\nevents.forEach((event) => {\n  eventPoints[event.index] = roundedPrices[event.index];\n});\n\nfunction drawRoundRect(ctx, x, y, w, h, r) {\n  ctx.beginPath();\n  ctx.moveTo(x + r, y);\n  ctx.arcTo(x + w, y, x + w, y + h, r);\n  ctx.arcTo(x + w, y + h, x, y + h, r);\n  ctx.arcTo(x, y + h, x, y, r);\n  ctx.arcTo(x, y, x + w, y, r);\n  ctx.closePath();\n}\n\n// --- Custom plugin: blackout band + event flags -------------------------------\nconst eventFlagsPlugin = {\n  id: \"eventFlags\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const xStart = scales.x.getPixelForValue(BLACKOUT.startIndex);\n    const xEnd = scales.x.getPixelForValue(BLACKOUT.endIndex);\n    ctx.save();\n    ctx.fillStyle = INK_MUTED;\n    ctx.globalAlpha = 0.12;\n    ctx.fillRect(xStart, chartArea.top, xEnd - xStart, chartArea.bottom - chartArea.top);\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    ctx.save();\n    ctx.font = \"600 15px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n\n    events.forEach((event, i) => {\n      const meta = EVENT_TYPES[event.type];\n      const xPixel = scales.x.getPixelForValue(event.index);\n      const priceY = scales.y.getPixelForValue(roundedPrices[event.index]);\n      const up = i % 2 === 0;\n      const far = Math.floor(i / 2) % 2 === 1;\n\n      const flagW = ctx.measureText(event.label).width + 28;\n      const flagH = 46;\n      const offset = far ? 96 : 24;\n      const flagY = up ? chartArea.top + offset : chartArea.bottom - offset - flagH;\n\n      // Dashed connector down to the exact price level for that date.\n      ctx.setLineDash([5, 4]);\n      ctx.strokeStyle = t.inkSoft;\n      ctx.lineWidth = 1.5;\n      ctx.beginPath();\n      ctx.moveTo(xPixel, up ? flagY + flagH : flagY);\n      ctx.lineTo(xPixel, priceY);\n      ctx.stroke();\n      ctx.setLineDash([]);\n\n      // Anchor dot at the price point.\n      ctx.fillStyle = meta.color;\n      ctx.beginPath();\n      ctx.arc(xPixel, priceY, 5, 0, Math.PI * 2);\n      ctx.fill();\n\n      // Flag badge with event label.\n      ctx.fillStyle = meta.color;\n      ctx.strokeStyle = t.pageBg;\n      ctx.lineWidth = 2.5;\n      drawRoundRect(ctx, xPixel - flagW / 2, flagY, flagW, flagH, 9);\n      ctx.fill();\n      ctx.stroke();\n\n      ctx.fillStyle = \"#FFFFFF\";\n      ctx.fillText(event.label, xPixel, flagY + flagH / 2);\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"line\",\n  data: {\n    labels,\n    datasets: [\n      {\n        label: \"Close Price\",\n        data: roundedPrices,\n        borderColor: t.palette[0],\n        backgroundColor: t.palette[0],\n        borderWidth: 3,\n        pointRadius: 0,\n        tension: 0.15,\n        fill: false,\n      },\n      {\n        label: \"Event Detail\",\n        data: eventPoints,\n        showLine: false,\n        pointRadius: 0,\n        pointHoverRadius: 9,\n        pointHitRadius: 18,\n        pointBackgroundColor: \"transparent\",\n        pointBorderColor: \"transparent\",\n      },\n    ],\n  },\n  plugins: [eventFlagsPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 6, bottom: 6, left: 6, right: 18 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"stock-event-flags · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      tooltip: {\n        callbacks: {\n          label: (context) => {\n            if (context.dataset.label === \"Event Detail\") {\n              const event = events.find((e) => e.index === context.dataIndex);\n              return event ? `${EVENT_TYPES[event.type].label}: ${event.label}` : \"\";\n            }\n            return `Close: $${context.parsed.y.toFixed(2)}`;\n          },\n        },\n      },\n      legend: {\n        display: true,\n        position: \"top\",\n        onClick: () => {},\n        labels: {\n          color: t.ink,\n          font: { size: 15 },\n          usePointStyle: true,\n          boxWidth: 12,\n          generateLabels: () => [\n            { text: \"Close Price\", fillStyle: t.palette[0], strokeStyle: t.palette[0], lineWidth: 3, pointStyle: \"line\" },\n            { text: EVENT_TYPES.earnings.label, fillStyle: EVENT_TYPES.earnings.color, strokeStyle: EVENT_TYPES.earnings.color, pointStyle: \"rect\" },\n            { text: EVENT_TYPES.dividend.label, fillStyle: EVENT_TYPES.dividend.color, strokeStyle: EVENT_TYPES.dividend.color, pointStyle: \"rect\" },\n            { text: EVENT_TYPES.split.label, fillStyle: EVENT_TYPES.split.color, strokeStyle: EVENT_TYPES.split.color, pointStyle: \"rect\" },\n            { text: EVENT_TYPES.news.label, fillStyle: EVENT_TYPES.news.color, strokeStyle: EVENT_TYPES.news.color, pointStyle: \"rect\" },\n          ],\n        },\n      },\n    },\n    scales: {\n      x: {\n        ticks: { color: t.inkSoft, font: { size: 14 }, maxTicksLimit: 10, autoSkip: true },\n        grid: { display: false },\n        title: { display: true, text: \"Trading Date\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        min: yMin,\n        max: yMax,\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: \"Close Price (USD)\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n});\n"}