388 lines
15 KiB
JavaScript
388 lines
15 KiB
JavaScript
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);
|
|
}
|