function currentChartPalette() { const style = getComputedStyle(document.documentElement); const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback; return { background: color("--chart-background", "#fbfcfd"), grid: color("--chart-grid", "#e2e8ec"), axis: color("--chart-axis", "#6c7983"), zero: color("--chart-zero", "#aeb7c1"), line: color("--chart-line", "#1d65c1"), average: color("--chart-average", "#b7791f"), up: color("--chart-up", "#c93f45"), down: color("--chart-down", "#087a55"), upVolume: color("--chart-up-volume", "rgba(201, 63, 69, .58)"), downVolume: color("--chart-down-volume", "rgba(8, 122, 85, .58)"), area: color("--chart-area", "rgba(37, 99, 235, .07)"), alertArea: color("--chart-alert-area", "rgba(224, 69, 54, .05)"), movingAverage: color("--chart-moving-average", "#d1d5db"), repair: color("--chart-repair", "#f59e0b"), ma10: color("--chart-ma-10", "#a76500"), ma20: color("--chart-ma-20", "#626c78"), }; } function drawCandlestick(context, x, item, priceY, candleWidth, palette = currentChartPalette()) { const rising = number(item.close) >= number(item.open); const color = rising ? palette.up : palette.down; const highY = priceY(item.high); const lowY = priceY(item.low); const openY = priceY(item.open); const closeY = priceY(item.close); const bodyTop = Math.min(openY, closeY); const bodyBottom = Math.max(openY, closeY); const bodyHeight = Math.max(1, bodyBottom - bodyTop); context.strokeStyle = color; context.fillStyle = color; context.lineWidth = 1; context.beginPath(); context.moveTo(x, highY); context.lineTo(x, bodyTop); context.moveTo(x, bodyBottom); context.lineTo(x, lowY); context.stroke(); const bodyLeft = x - candleWidth / 2; if (rising) { context.fillStyle = palette.background; context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight); context.strokeStyle = color; context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight); } else { context.fillStyle = color; context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight); } return color; } function drawPriceChart(prices) { const canvas = elements.priceChart; if (!prices?.length) { clearPriceChart("暂无日 K 数据"); return; } const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(320, rect.width); const height = Math.max(220, rect.height); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); const left = 48; const right = 12; const top = 14; const bottom = 22; const volumeHeight = 54; const gap = 12; const priceBottom = height - bottom - volumeHeight - gap; const plotWidth = width - left - right; const highs = prices.map((item) => number(item.high)); const lows = prices.map((item) => number(item.low)); const maximum = Math.max(...highs); const minimum = Math.min(...lows); const range = Math.max(maximum - minimum, maximum * 0.01, 0.01); const volumes = prices.map((item) => number(item.volume)); const maxVolume = Math.max(...volumes, 1); const priceY = (value) => top + (maximum - value) / range * (priceBottom - top); const step = plotWidth / prices.length; const candleWidth = clamp(step * 0.62, 2, 8); context.strokeStyle = palette.grid; context.fillStyle = palette.axis; context.font = "11px Microsoft YaHei"; context.textAlign = "right"; for (let line = 0; line <= 4; line += 1) { const y = top + (priceBottom - top) * line / 4; context.beginPath(); context.moveTo(left, y); context.lineTo(width - right, y); context.stroke(); context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4); } prices.forEach((item, index) => { const x = left + step * index + step / 2; const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.75; context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight); context.globalAlpha = 1; }); context.textAlign = "center"; context.fillStyle = palette.axis; const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1]; labelIndexes.forEach((index) => { const x = left + step * index + step / 2; context.fillText(String(prices[index].trade_date).slice(5), x, height - 5); }); } function clearPriceChart(message) { const canvas = elements.priceChart; const context = canvas.getContext("2d"); const rect = canvas.getBoundingClientRect(); canvas.width = Math.max(320, Math.round(rect.width)); canvas.height = Math.max(220, Math.round(rect.height)); const palette = currentChartPalette(); context.fillStyle = palette.background; context.fillRect(0, 0, canvas.width, canvas.height); context.fillStyle = palette.axis; context.font = "13px Microsoft YaHei"; context.textAlign = "center"; context.fillText(message, canvas.width / 2, canvas.height / 2); } function prepareStockPreviewCanvas() { const canvas = elements.stockPreviewChart; const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(300, rect.width || 488); const height = Math.max(210, rect.height || 232); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif'; return { canvas, context, width, height, palette }; } function drawPreviewGrid(context, width, top, bottom, left, right, maximum, range) { const palette = currentChartPalette(); context.strokeStyle = palette.grid; context.fillStyle = palette.axis; context.textAlign = "right"; context.lineWidth = 1; for (let line = 0; line <= 3; line += 1) { const y = top + (bottom - top) * line / 3; context.beginPath(); context.moveTo(left, y); context.lineTo(width - right, y); context.stroke(); context.fillText((maximum - range * line / 3).toFixed(2), left - 5, y + 4); } } function intradayMinuteOffset(value) { const [hour, minute] = String(value || "").split(":").map((part) => number(part)); const clockMinute = hour * 60 + minute; const morningStart = 9 * 60 + 30; const morningEnd = 11 * 60 + 30; const afternoonStart = 13 * 60; const afternoonEnd = 15 * 60; if (clockMinute <= morningEnd) return clamp(clockMinute - morningStart, 0, 120); if (clockMinute < afternoonStart) return 120; return 120 + clamp(clockMinute - afternoonStart, 0, afternoonEnd - afternoonStart); } function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0) { const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(300, rect.width || 488); const height = Math.max(210, rect.height || 232); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif'; const left = 45; const right = 10; const top = 12; const volumeHeight = 38; const bottom = 18; const gap = 9; const priceBottom = height - bottom - volumeHeight - gap; const closes = points.map((point) => number(point.close)); const previousClose = number(referenceClose || dailyPrices.at(-2)?.close || points[0]?.open || closes[0]); const maximum = Math.max(...points.map((point) => number(point.high || point.close)), previousClose); const minimum = Math.min(...points.map((point) => number(point.low || point.close)), previousClose); const deviation = Math.max( Math.abs(maximum - previousClose), Math.abs(previousClose - minimum), previousClose * 0.003, 0.01, ) * 1.08; const chartMaximum = previousClose + deviation; const chartMinimum = previousClose - deviation; const range = Math.max(chartMaximum - chartMinimum, 0.01); const plotWidth = width - left - right; const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top); const pointX = (index) => left + plotWidth * intradayMinuteOffset(points[index]?.time) / 240; drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range); context.save(); context.setLineDash([4, 4]); context.strokeStyle = palette.zero; context.beginPath(); context.moveTo(left, priceY(previousClose)); context.lineTo(width - right, priceY(previousClose)); context.stroke(); context.restore(); context.fillStyle = palette.axis; context.textAlign = "right"; context.fillText("0.00%", width - right, priceY(previousClose) - 4); context.strokeStyle = palette.line; context.lineWidth = 1.7; context.beginPath(); points.forEach((point, index) => { const x = pointX(index); const y = priceY(point.close); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); const averages = points.map((point) => number(point.average)).filter((value) => value > 0); if (averages.length) { context.strokeStyle = palette.average; context.lineWidth = 1.25; context.beginPath(); let averageStarted = false; points.forEach((point, index) => { const average = number(point.average); if (average <= 0) return; const x = pointX(index); const y = priceY(average); if (!averageStarted) { context.moveTo(x, y); averageStarted = true; } else context.lineTo(x, y); }); context.stroke(); } const maxVolume = Math.max(...points.map((point) => number(point.volume)), 1); const barWidth = clamp(plotWidth / Math.max(points.length, 1) * 0.72, 1, 3); points.forEach((point, index) => { const x = pointX(index); const barHeight = number(point.volume) / maxVolume * volumeHeight; context.fillStyle = number(point.close) >= number(point.open) ? palette.upVolume : palette.downVolume; context.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight); }); context.fillStyle = palette.axis; context.textAlign = "center"; [ { offset: 0, label: "09:30" }, { offset: 120, label: "11:30 / 13:00" }, { offset: 240, label: "15:00" }, ].forEach((marker) => { context.fillText(marker.label, left + plotWidth * marker.offset / 240, height - 4); }); return { latest: closes.at(-1), maximum, minimum, }; } function drawIntradayPreviewChart(points, dailyPrices, referenceClose = 0) { const summary = drawIntradayCanvas(elements.stockPreviewChart, points, dailyPrices, referenceClose); setText( "stockPreviewSummary", `分时 ${points.length} 点,最新 ${formatNumber(summary.latest, 2)},最高 ${formatNumber(summary.maximum, 2)},最低 ${formatNumber(summary.minimum, 2)}。`, ); } function drawDailyPreviewChart(prices) { const { context, width, height, palette } = prepareStockPreviewCanvas(); const visible = prices.slice(-45); const visibleStart = prices.length - visible.length; const left = 45; const right = 10; const top = 24; const volumeHeight = 34; const bottom = 18; const gap = 8; const priceBottom = height - bottom - volumeHeight - gap; const maximum = Math.max(...visible.map((item) => number(item.high))); const minimum = Math.min(...visible.map((item) => number(item.low))); const padding = Math.max((maximum - minimum) * 0.05, maximum * 0.002, 0.01); const chartMaximum = maximum + padding; const chartMinimum = minimum - padding; const range = Math.max(chartMaximum - chartMinimum, 0.01); const plotWidth = width - left - right; const step = plotWidth / Math.max(visible.length, 1); const candleWidth = clamp(step * 0.58, 2, 7); const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top); drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range); const maxVolume = Math.max(...visible.map((item) => number(item.volume)), 1); visible.forEach((item, index) => { const x = left + step * index + step / 2; const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.62; context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight); context.globalAlpha = 1; }); const movingAverages = [ { days: 5, color: palette.line }, { days: 10, color: palette.ma10 }, { days: 20, color: palette.ma20 }, ]; movingAverages.forEach(({ days, color }) => { context.strokeStyle = color; context.lineWidth = 1.25; context.beginPath(); let started = false; visible.forEach((_item, index) => { const absoluteIndex = visibleStart + index; if (absoluteIndex < days - 1) return; const values = prices.slice(absoluteIndex - days + 1, absoluteIndex + 1); const average = values.reduce((sum, item) => sum + number(item.close), 0) / days; const x = left + step * index + step / 2; const y = priceY(average); if (!started) { context.moveTo(x, y); started = true; } else context.lineTo(x, y); }); context.stroke(); }); context.textAlign = "left"; movingAverages.forEach(({ days, color }, index) => { context.fillStyle = color; context.fillText(`MA${days}`, left + index * 42, 12); }); context.fillStyle = palette.axis; context.textAlign = "center"; [0, Math.floor((visible.length - 1) / 2), visible.length - 1].forEach((index) => { const x = left + step * index + step / 2; context.fillText(String(visible[index]?.trade_date || "").slice(5), x, height - 4); }); const firstClose = number(visible[0]?.close); const latestClose = number(visible.at(-1)?.close); const periodChange = firstClose ? (latestClose / firstClose - 1) * 100 : 0; setText( "stockPreviewSummary", `近 ${visible.length} 日涨跌 ${signed(periodChange)}%,区间最高 ${formatNumber(maximum, 2)},最低 ${formatNumber(minimum, 2)}。`, ); } function clearStockPreviewChart(message) { const { context, width, height } = prepareStockPreviewCanvas(); if (!message) return; context.fillStyle = "#74808d"; context.textAlign = "center"; context.fillText(message, width / 2, height / 2); }