{"spec_id":"contour-filled","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// contour-filled: Filled Contour Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-09-04\n\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: precipitation-intensity field over a regional grid --------------\n// Chart.js has no native contour/isoband chart type (that lives in unpinned\n// community plugins, out of scope). Instead this samples the field on a\n// regular grid — exactly what the spec calls for — then rasterizes bilinearly\n// interpolated, level-quantized bands via Chart.js's own draw-hook `plugins`\n// API (native, not a plugin package) onto a real linear x/y coordinate system.\nconst N = 60;\nconst X_MIN = 0, X_MAX = 120; // km east\nconst Y_MIN = 0, Y_MAX = 70; // km north\nconst NUM_LEVELS = 14;\n\nconst xArr = [];\nfor (let i = 0; i < N; i++) xArr.push(X_MIN + ((X_MAX - X_MIN) * i) / (N - 1));\nconst yArr = [];\nfor (let j = 0; j < N; j++) yArr.push(Y_MIN + ((Y_MAX - Y_MIN) * j) / (N - 1));\n\nfunction gaussianBump(x, y, cx, cy, sx, sy, amp) {\n  const dx = (x - cx) / sx;\n  const dy = (y - cy) / sy;\n  return amp * Math.exp(-0.5 * (dx * dx + dy * dy));\n}\n\n// Three storm cells of different size/intensity over ambient drizzle — mm/hr,\n// always >= 0 — for a more textured field than a symmetric two-bump pair.\nlet Z_MIN = Infinity, Z_MAX = -Infinity;\nconst zGrid = [];\nfor (let i = 0; i < N; i++) {\n  zGrid.push([]);\n  for (let j = 0; j < N; j++) {\n    const x = xArr[i], y = yArr[j];\n    const z =\n      1.4 +\n      gaussianBump(x, y, 42, 48, 16, 12, 44) +\n      gaussianBump(x, y, 88, 22, 13, 10, 24) +\n      gaussianBump(x, y, 18, 18, 9, 7, 12);\n    zGrid[i].push(z);\n    if (z < Z_MIN) Z_MIN = z;\n    if (z > Z_MAX) Z_MAX = z;\n  }\n}\nconst LEVEL_STEP = (Z_MAX - Z_MIN) / NUM_LEVELS;\n\n// --- Bilinear interpolation over the sampled grid ---------------------------\nfunction gridFraction(v, vMin, vMax, count) {\n  const f = ((v - vMin) / (vMax - vMin)) * (count - 1);\n  const i0 = Math.max(0, Math.min(count - 2, Math.floor(f)));\n  return { i0, frac: f - i0 };\n}\n\nfunction interpZ(x, y) {\n  const gx = gridFraction(x, X_MIN, X_MAX, N);\n  const gy = gridFraction(y, Y_MIN, Y_MAX, N);\n  const z00 = zGrid[gx.i0][gy.i0];\n  const z10 = zGrid[gx.i0 + 1][gy.i0];\n  const z01 = zGrid[gx.i0][gy.i0 + 1];\n  const z11 = zGrid[gx.i0 + 1][gy.i0 + 1];\n  const zTop = z00 + (z10 - z00) * gx.frac;\n  const zBot = z01 + (z11 - z01) * gx.frac;\n  return zTop + (zBot - zTop) * gy.frac;\n}\n\n// --- Imprint sequential colormap (t.seq), quantized into level bands -------\nfunction hexToRgb(hex) {\n  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\n}\nconst SEQ_LO = hexToRgb(t.seq[0]);\nconst SEQ_HI = hexToRgb(t.seq[1]);\n\nfunction levelOf(z) {\n  let level = Math.floor((z - Z_MIN) / LEVEL_STEP);\n  if (level >= NUM_LEVELS) level = NUM_LEVELS - 1;\n  if (level < 0) level = 0;\n  return level;\n}\n\nfunction bandRgb(level) {\n  const frac = (level + 0.5) / NUM_LEVELS;\n  return [\n    Math.round(SEQ_LO[0] + (SEQ_HI[0] - SEQ_LO[0]) * frac),\n    Math.round(SEQ_LO[1] + (SEQ_HI[1] - SEQ_LO[1]) * frac),\n    Math.round(SEQ_LO[2] + (SEQ_HI[2] - SEQ_LO[2]) * frac),\n  ];\n}\n\n// Renders the level-banded field into an offscreen raster canvas at a fixed\n// pixel budget, then it gets scaled into the chart area by the draw plugin.\nfunction renderBands(gridW, gridH) {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = gridW;\n  canvas.height = gridH;\n  const ctx = canvas.getContext(\"2d\");\n  const img = ctx.createImageData(gridW, gridH);\n  const data = img.data;\n  for (let py = 0; py < gridH; py++) {\n    const y = Y_MAX - (py / (gridH - 1)) * (Y_MAX - Y_MIN);\n    for (let px = 0; px < gridW; px++) {\n      const x = X_MIN + (px / (gridW - 1)) * (X_MAX - X_MIN);\n      const [r, g, b] = bandRgb(levelOf(interpZ(x, y)));\n      const idx = (py * gridW + px) * 4;\n      data[idx] = r;\n      data[idx + 1] = g;\n      data[idx + 2] = b;\n      data[idx + 3] = 255;\n    }\n  }\n  ctx.putImageData(img, 0, 0);\n  return canvas;\n}\n\n// --- Marching squares for the band-boundary isolines ------------------------\n// For each 4-bit corner code (BL=bit0, BR=bit1, TR=bit2, TL=bit3, 1=above\n// threshold), which pairs of edge indices connect as a line segment.\n// Edges: 0=bottom (BL-BR), 1=right (BR-TR), 2=top (TL-TR), 3=left (BL-TL)\nconst SEG = [\n  [], [[0, 3]], [[0, 1]], [[3, 1]],\n  [[1, 2]], [[0, 3], [1, 2]], [[0, 2]], [[3, 2]],\n  [[3, 2]], [[0, 2]], [[0, 1], [2, 3]], [[1, 2]],\n  [[3, 1]], [[0, 1]], [[0, 3]], [],\n];\n\n// Precise levels for identification — every band boundary drawn as a thin\n// isoline lets a viewer read off exact contour crossings within a band.\nconst isoThresholds = [];\nfor (let lvl = 1; lvl < NUM_LEVELS; lvl++) isoThresholds.push(Z_MIN + lvl * LEVEL_STEP);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\nlet bandCanvas = null;\n\nconst contourPlugin = {\n  id: \"contourFill\",\n  afterDraw(chart) {\n    const ctx = chart.ctx;\n    const ca = chart.chartArea;\n    if (!ca || !bandCanvas) return;\n    const areaW = ca.right - ca.left;\n    const areaH = ca.bottom - ca.top;\n\n    ctx.save();\n    ctx.drawImage(bandCanvas, ca.left, ca.top, areaW, areaH);\n\n    // --- Isolines at every band boundary, clipped to the plot area ---------\n    const xs = chart.scales.x, ys = chart.scales.y;\n    const xPx = xArr.map((v) => xs.getPixelForValue(v));\n    const yPx = yArr.map((v) => ys.getPixelForValue(v));\n\n    function edgePx(e, i, j, z00, z10, z11, z01, thresh) {\n      const f = (a, b, za, zb) => a + ((thresh - za) / (zb - za)) * (b - a);\n      switch (e) {\n        case 0: return [f(xPx[i], xPx[i + 1], z00, z10), yPx[j]];\n        case 1: return [xPx[i + 1], f(yPx[j], yPx[j + 1], z10, z11)];\n        case 2: return [f(xPx[i], xPx[i + 1], z01, z11), yPx[j + 1]];\n        case 3: return [xPx[i], f(yPx[j], yPx[j + 1], z00, z01)];\n      }\n    }\n\n    ctx.beginPath();\n    ctx.rect(ca.left, ca.top, areaW, areaH);\n    ctx.clip();\n    ctx.beginPath();\n    ctx.strokeStyle = t.ink;\n    ctx.lineWidth = 0.75;\n    ctx.globalAlpha = 0.3;\n    for (const thresh of isoThresholds) {\n      for (let i = 0; i < N - 1; i++) {\n        for (let j = 0; j < N - 1; j++) {\n          const z00 = zGrid[i][j], z10 = zGrid[i + 1][j];\n          const z11 = zGrid[i + 1][j + 1], z01 = zGrid[i][j + 1];\n          const code =\n            (z00 >= thresh ? 1 : 0) | (z10 >= thresh ? 2 : 0) |\n            (z11 >= thresh ? 4 : 0) | (z01 >= thresh ? 8 : 0);\n          for (const [e0, e1] of SEG[code]) {\n            const [ax, ay] = edgePx(e0, i, j, z00, z10, z11, z01, thresh);\n            const [bx, by] = edgePx(e1, i, j, z00, z10, z11, z01, thresh);\n            ctx.moveTo(ax, ay);\n            ctx.lineTo(bx, by);\n          }\n        }\n      }\n    }\n    ctx.stroke();\n    ctx.restore();\n\n    // --- Colorbar ------------------------------------------------------------\n    const barX = ca.right + 26;\n    const barW = 26;\n    const barH = areaH;\n\n    // Stepped bands matching the plot's own level quantization, so the\n    // colorbar reads as the same 14 discrete bands rather than a smooth ramp.\n    for (let level = 0; level < NUM_LEVELS; level++) {\n      const [r, g, b] = bandRgb(level);\n      const bandTop = ca.bottom - ((level + 1) / NUM_LEVELS) * barH;\n      const bandHeight = barH / NUM_LEVELS;\n      ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;\n      ctx.fillRect(barX, bandTop, barW, bandHeight);\n    }\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 0.75;\n    ctx.globalAlpha = 0.5;\n    for (let level = 1; level < NUM_LEVELS; level++) {\n      const boundaryY = ca.bottom - (level / NUM_LEVELS) * barH;\n      ctx.beginPath();\n      ctx.moveTo(barX, boundaryY);\n      ctx.lineTo(barX + barW, boundaryY);\n      ctx.stroke();\n    }\n    ctx.globalAlpha = 1;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, ca.top, barW, barH);\n\n    ctx.fillStyle = t.ink;\n    ctx.font = \"bold 15px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.fillText(\"mm/hr\", barX + barW / 2, ca.top - 10);\n\n    ctx.strokeStyle = t.inkSoft;\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"15px sans-serif\";\n    ctx.textAlign = \"left\";\n    const ticks = [\n      { frac: 1, label: Z_MAX.toFixed(1) },\n      { frac: 0.5, label: ((Z_MIN + Z_MAX) / 2).toFixed(1) },\n      { frac: 0, label: Z_MIN.toFixed(1) },\n    ];\n    for (const tk of ticks) {\n      const ty = ca.bottom - tk.frac * barH;\n      ctx.beginPath();\n      ctx.moveTo(barX + barW, ty);\n      ctx.lineTo(barX + barW + 5, ty);\n      ctx.stroke();\n      ctx.fillText(tk.label, barX + barW + 8, ty + 5);\n    }\n    ctx.restore();\n  },\n};\n\n// --- Title (fontsize scales down when the title runs past the ~67-char\n// mandated-title baseline — see prompts/plot-generator.md) ------------------\nconst TITLE = \"Storm System Precipitation · contour-filled · javascript · chartjs · anyplot.ai\";\nconst TITLE_DEFAULT_SIZE = 22;\nconst TITLE_FLOOR = 15;\nconst titleFontSize = Math.max(TITLE_FLOOR, Math.round(TITLE_DEFAULT_SIZE * Math.min(1, 67 / TITLE.length)));\n\n// --- Chart -------------------------------------------------------------------\n// A scatter chart with an empty dataset supplies the real linear x/y\n// coordinate system the contourFill plugin draws the raster and isolines into.\nconst chart = new Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [{ data: [] }] },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { right: 110, top: 10, bottom: 10 } },\n    plugins: {\n      title: {\n        display: true,\n        text: TITLE,\n        color: t.ink,\n        font: { size: titleFontSize },\n        padding: { top: 12, bottom: 16 },\n      },\n      legend: { display: false },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: X_MIN,\n        max: X_MAX,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n        title: { display: true, text: \"Distance east (km)\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        type: \"linear\",\n        min: Y_MIN,\n        max: Y_MAX,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n        title: { display: true, text: \"Distance north (km)\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n  plugins: [contourPlugin],\n});\n\n// Build the raster at a resolution matched to the chart area's aspect ratio,\n// now that layout has settled and chartArea is known.\nconst area = chart.chartArea;\nconst areaAspect = (area.right - area.left) / (area.bottom - area.top);\nconst PIXEL_BUDGET = 520000;\nconst gridH = Math.round(Math.sqrt(PIXEL_BUDGET / areaAspect));\nconst gridW = Math.round(gridH * areaAspect);\nbandCanvas = renderBands(gridW, gridH);\n\nchart.update(\"none\");\n\nwindow.__anyplotReady = true;\n"}