1import { Controller } from "@hotwired/stimulus" 2 3// Shared, CVD-validated, theme-aware --chart-* palette (null if unresolved, so the 4// caller falls back to the legacy hardcoded hex â the chart can never fail to render). 5// Tokens resolve to hex literals, so downstream alphaColor(hex) parsing stays valid. 6function chartTokenPalette() { 7 try { 8 const s = getComputedStyle(document.documentElement) 9 const vals = ["--chart-s1", "--chart-s2", "--chart-s3", "--chart-s4", "--chart-s5", "--chart-s6", "--chart-s7", "--chart-s8", "--chart-other"] 10 .map((v) => s.getPropertyValue(v).trim()) 11 .filter((c) => c.startsWith("#")) 12 return vals.length >= 8 ? vals : null 13 } catch (e) { 14 return null 15 } 16} 17 18export default class extends Controller { 19 static targets = ["canvas"] 20 static values = { 21 labels: Array, 22 datasets: Array, 23 currency: { type: Boolean, default: false }, 24 title: { type: String, default: "" }, 25 stacked: { type: Boolean, default: false }, 26 area: { type: Boolean, default: true }, 27 priorLabels: { type: Array, default: [] } 28 } 29 30 connect() { 31 if (typeof Chart === "undefined") { 32 console.warn("Chart.js not loaded yet") 33 return 34 } 35 this.render() 36 } 37 38 disconnect() { 39 if (this.chart) { 40 this.chart.destroy() 41 this.chart = null 42 } 43 // Remove custom tooltip element 44 if (this._tooltipEl) { 45 this._tooltipEl.remove() 46 this._tooltipEl = null 47 } 48 } 49 50 render() { 51 const ctx = this.canvasTarget.getContext("2d") 52 const isDark = document.documentElement.getAttribute("data-theme") === "dark" 53 const gridColor = isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)" 54 const textColor = isDark ? "#e4e4e7" : "#64748b" 55 56 const rawDs = this.datasetsValue 57 const hasPrior = this.priorLabelsValue && this.priorLabelsValue.length > 0 58 59 const datasets = rawDs.map((ds, i) => { 60 const color = ds.color || this.palette(i) 61 const isDashed = ds.dashed === true 62 return { 63 label: ds.label || `Series ${i + 1}`, 64 data: ds.data, 65 borderColor: color, 66 backgroundColor: isDashed 67 ? "transparent" 68 : this.areaValue 69 ? this.alphaColor(color, 0.12) 70 : "transparent", 71 fill: isDashed ? false : this.areaValue, 72 tension: 0.35, 73 borderWidth: isDashed ? 1.5 : 2.5, 74 borderDash: isDashed ? [6, 4] : [], 75 pointRadius: isDashed ? 0 : (ds.data.length > 30 ? 0 : 3), 76 pointHoverRadius: isDashed ? 3 : 5,
77 pointBackgroundColor: color, 78 order: isDashed ? 1 : 0, 79 } 80 }) 81 82 // Build tooltip config â use custom HTML tooltip when prior data exists 83 const tooltipConfig = hasPrior 84 ? { enabled: false, external: (context) => this.externalTooltip(context) } 85 : { 86 backgroundColor: isDark ? "#2c2c2e" : "#1e293b", 87 titleColor: "#f8fafc", 88 bodyColor: "#f8fafc", 89 borderColor: isDark ? "#404040" : "#334155", 90 borderWidth: 1, 91 padding: 12, 92 cornerRadius: 8, 93 callbacks: this.currencyValue ? { 94 label: (ctx) => `${ctx.dataset.label}: $${ctx.parsed.y.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` 95 } : {} 96 } 97 98 this.chart = new Chart(ctx, { 99 type: "line", 100 data: { labels: this.labelsValue, datasets }, 101 options: { 102 responsive: true, 103 maintainAspectRatio: false, 104 interaction: { mode: "index", intersect: false }, 105 plugins: { 106 legend: { 107 display: datasets.length > 1, 108 position: "top", 109 labels: { 110 color: textColor, 111 padding: 16, 112 font: { size: 12 }, 113 usePointStyle: false, 114 boxWidth: 24, 115 boxHeight: 2, 116 generateLabels: (chart) => { 117 return chart.data.datasets.map((ds, i) => { 118 const isDashed = rawDs[i]?.dashed === true 119 return { 120 text: ds.label, 121 fillStyle: "transparent", 122 strokeStyle: ds.borderColor, 123 lineWidth: isDashed ? 1.5 : 2.5, 124 lineDash: isDashed ? [6, 4] : [], 125 hidden: !chart.isDatasetVisible(i), 126 datasetIndex: i, 127 fontColor: textColor, 128 } 129 }) 130 } 131 } 132 }, 133 tooltip: tooltipConfig 134 }, 135 scales: { 136 x: { 137 grid: { display: false }, 138 ticks: { color: textColor, font: { size: 11 }, maxRotation: 45 } 139 }, 140 y: { 141 stacked: this.stackedValue, 142 beginAtZero: true, 143 grid: { color: gridColor }, 144 ticks: { 145 color: textColor, 146 font: { size: 11 }, 147 callback: this.currencyValue 148 ? (v) => "$" + v.toLocaleString() 149 : undefined 150 } 151 } 152 } 153 } 154 }) 155 156 this.observeTheme() 157 } 158 159 externalTooltip(context) { 160 const { chart, tooltip } = context 161 const rawDs = this.datasetsValue 162 const priorLabels = this.priorLabelsValue 163 const isCurrency = this.currencyValue 164 165 // Get or create tooltip element inside a position:relative wrapper 166 let el = this._tooltipEl 167 if (!el) { 168 // Ensure parent is position:relative so absolute positioning works 169 const parent = chart.canvas.parentNode 170 if (getComputedStyle(parent).position === "static") { 171 parent.style.position = "relative" 172 } 173 el = document.createElement("div") 174 el.className = "cl-custom-tooltip" 175 el.innerHTML = "<div class='cl-tt-inner'></div>" 176 parent.appendChild(el) 177 this._tooltipEl = el 178 } 179 180 // Hide if no tooltip 181 if (tooltip.opacity === 0) { 182 el.style.opacity = "0" 183 el.style.pointerEvents = "none" 184 return 185 } 186 187 const idx = tooltip.dataPoints?.[0]?.dataIndex ?? 0 188 const currentDate = tooltip.title?.[0] || this.labelsValue[idx] || "" 189 const priorDate = priorLabels[idx] || "" 190 191 // Separate current vs prior items 192 const currentItems = [] 193 const priorItems = [] 194 tooltip.dataPoints.forEach(dp => { 195 const ds = rawDs[dp.datasetIndex] 196 const isDashed = ds?.dashed === true 197 const color = dp.dataset.borderColor 198 const val = isCurrency 199 ? "$" + dp.parsed.y.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) 200 : dp.parsed.y.toLocaleString() 201 const label = isDashed ? (ds.label || "").replace(/^Prior\s+/i, "") : (ds.label || "") 202 const item = { color, label, val } 203 if (isDashed) priorItems.push(item) 204 else currentItems.push(item) 205 }) 206 207 // Build table HTML 208 let html = `<div class="cl-tt-inner"><table class="cl-tt-table"><thead><tr>` 209 html += `<th></th>` 210 html += `<th class="cl-tt-cur-head">${currentDate}</th>` 211 if (priorDate) html += `<th class="cl-tt-pri-head">${priorDate}</th>` 212 html += `</tr></thead><tbody>` 213 214 const rowCount = Math.max(currentItems.length, priorItems.length) 215 for (let r = 0; r < rowCount; r++) { 216 const cur = currentItems[r] 217 const pri = priorItems[r] 218 const label = cur ? cur.label : (pri ? pri.label : "") 219 const dotColor = cur ? cur.color : (pri ? pri.color : "#666") 220 221 html += `<tr>` 222 html += `<td><span class="cl-tt-dot" style="background:${dotColor}"></span>${label}</td>` 223 html += `<td class="cl-tt-cur">${cur ? cur.val : "â"}</td>` 224 if (priorDate) html += `<td class="cl-tt-pri">${pri ? pri.val : "â"}</td>` 225 html += `</tr>` 226 } 227 228 html += `</tbody></table></div>` 229 el.innerHTML = html 230 231 // Position 232 el.style.opacity = "1" 233 el.style.pointerEvents = "none" 234 235 const canvasRect = chart.canvas.getBoundingClientRect() 236 const ttWidth = el.offsetWidth 237 const ttHeight = el.offsetHeight 238 let left = tooltip.caretX + 12 239 let top = tooltip.caretY - ttHeight / 2 240 241 // Flip to left side if it would overflow 242 if (left + ttWidth > canvasRect.width) { 243 left = tooltip.caretX - ttWidth - 12 244 } 245 // Clamp vertical 246 if (top < 4) top = 4 247 if (top + ttHeight > canvasRect.height - 4) top = canvasRect.height - ttHeight - 4 248 249 el.style.left = left + "px" 250 el.style.top = top + "px" 251 } 252 253 palette(i) { 254 const colors = chartTokenPalette() || ["#23AAE0", "#8B5CF6", "#10B981", "#F59E0B", "#EF4444", "#EC4899", "#06B6D4", "#6366F1", "#94A3B8"] 255 return colors[i % colors.length] 256 } 257 258 alphaColor(hex, alpha) { 259 const r = parseInt(hex.slice(1, 3), 16) 260 const g = parseInt(hex.slice(3, 5), 16) 261 const b = parseInt(hex.slice(5, 7), 16) 262 return `rgba(${r},${g},${b},${alpha})` 263 } 264 265 observeTheme() { 266 this._observer = new MutationObserver(() => { 267 if (this.chart) { 268 this.chart.destroy() 269 this.render() 270 } 271 }) 272 this._observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }) 273 } 274};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.