Skip to content

Instantly share code, notes, and snippets.

@esshka
Created March 20, 2025 07:17
Show Gist options
  • Save esshka/318cdabc00b3decf6ec250c421ef9b04 to your computer and use it in GitHub Desktop.
Save esshka/318cdabc00b3decf6ec250c421ef9b04 to your computer and use it in GitHub Desktop.
simple candlesticks price chart on canvas
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Candlestick Chart</title>
<style>
/* Basic styles for the container and canvas */
body {
font-family: 'Arial', sans-serif;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f0f0f0;
}
.chart-container {
width: 100%;
height: 100%;
max-width: 800px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-top: 20px;
margin-bottom: 20px;
box-sizing: border-box;
display: flex;
overflow: hidden; /* Important: Prevent scrollbars during zoom/pan */
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab; /* Default cursor */
}
canvas.active {
cursor: grabbing; /* Cursor when dragging */
}
</style>
</head>
<body>
<div class="chart-container">
<canvas id="candlestickCanvas"></canvas>
</div>
<script>
// Get the canvas element and its context
const canvas = document.getElementById('candlestickCanvas');
const ctx = canvas.getContext('2d');
canvas.classList.add('grab');
// Mock data for the candlestick chart
const initialData = [
{ open: 160, high: 170, low: 150, close: 165, date: '2024-01-01' },
{ open: 165, high: 180, low: 160, close: 175, date: '2024-01-02' },
{ open: 175, high: 185, low: 170, close: 182, date: '2024-01-03' },
{ open: 182, high: 190, low: 180, close: 185, date: '2024-01-04' },
{ open: 185, high: 188, low: 175, close: 178, date: '2024-01-05' },
{ open: 178, high: 182, low: 170, close: 175, date: '2024-01-06' },
{ open: 175, high: 185, low: 172, close: 180, date: '2024-01-07' },
{ open: 180, high: 192, low: 178, close: 190, date: '2024-01-08' },
{ open: 190, high: 200, low: 188, close: 195, date: '2024-01-09' },
{ open: 195, high: 205, low: 192, close: 200, date: '2024-01-10' },
{ open: 200, high: 210, low: 198, close: 205, date: '2024-01-11' },
{ open: 205, high: 215, low: 202, close: 212, date: '2024-01-12' },
{ open: 212, high: 220, low: 208, close: 218, date: '2024-01-13' },
{ open: 218, high: 225, low: 215, close: 220, date: '2024-01-14' },
{ open: 220, high: 230, low: 218, close: 228, date: '2024-01-15' },
{ open: 228, high: 235, low: 225, close: 232, date: '2024-01-16' },
{ open: 232, high: 240, low: 230, close: 238, date: '2024-01-17' },
{ open: 238, high: 245, low: 235, close: 242, date: '2024-01-18' },
{ open: 242, high: 250, low: 240, close: 248, date: '2024-01-19' },
{ open: 248, high: 255, low: 245, close: 252, date: '2024-01-20' },
{ open: 252, high: 260, low: 250, close: 258, date: '2024-01-21' },
{ open: 258, high: 265, low: 255, close: 262, date: '2024-01-22' },
{ open: 262, high: 270, low: 260, close: 268, date: '2024-01-23' },
{ open: 268, high: 275, low: 265, close: 272, date: '2024-01-24' },
{ open: 272, high: 280, low: 270, close: 278, date: '2024-01-25' },
{ open: 278, high: 285, low: 275, close: 282, date: '2024-01-26' },
{ open: 282, high: 290, low: 280, close: 288, date: '2024-01-27' },
{ open: 288, high: 295, low: 285, close: 292, date: '2024-01-28' },
{ open: 292, high: 300, low: 290, close: 298, date: '2024-01-29' },
{ open: 298, high: 305, low: 295, close: 302, date: '2024-01-30' },
{ open: 302, high: 310, low: 300, close: 308, date: '2024-01-31' },
];
let data = initialData.slice();
let visibleData = data.slice();
let lowestPrice = Math.min(...data.map(d => Math.min(d.low, d.open, d.close)));
let highestPrice = Math.max(...data.map(d => Math.max(d.high, d.open, d.close)));
let zoomLevel = 1;
let panX = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let windowStart = 0;
let windowEnd = data.length;
const minVisibleCandles = 10; // Minimum number of candles to show
const maxVisibleCandles = 40; // Maximum number of candles to show
function updateVisibleData() {
visibleData = data.slice(windowStart, windowEnd);
lowestPrice = Math.min(...visibleData.map(d => Math.min(d.low, d.open, d.close)));
highestPrice = Math.max(...visibleData.map(d => Math.max(d.high, d.open, d.close)));
}
function drawCandlestickChart(canvas, ctx) {
if (!visibleData || visibleData.length === 0) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#666';
ctx.textAlign = 'center';
ctx.fillText('No data available', canvas.width / 2, canvas.height / 2);
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
const width = canvas.width;
const height = canvas.height;
let candleWidth = width / visibleData.length * 0.8;
const xPadding = (width / visibleData.length) * 0.2 / 2;
const yAxisWidth = 60;
const chartWidth = width - yAxisWidth;
const xAxisHeight = 30;
const usableHeight = height - xAxisHeight;
candleWidth = candleWidth * zoomLevel;
let visibleLowestPrice = lowestPrice;
let visibleHighestPrice = highestPrice;
const priceRange = visibleHighestPrice - visibleLowestPrice;
function priceToY(price) {
return usableHeight - (price - visibleLowestPrice) / priceRange * usableHeight;
}
function drawYAxis() {
ctx.font = '10px Arial';
ctx.fillStyle = '#666';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
const numLabels = 5;
for (let i = 0; i <= numLabels; i++) {
const price = visibleLowestPrice + (priceRange / numLabels) * i;
const y = priceToY(price);
ctx.fillText(price.toFixed(2), yAxisWidth - 5, y);
ctx.beginPath();
ctx.strokeStyle = '#ddd';
ctx.moveTo(yAxisWidth, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
// Draw the Y-axis
drawYAxis();
visibleData.forEach((item, index) => {
const x = yAxisWidth + xPadding + (index * (candleWidth + 2 * xPadding)) + panX;
const open = priceToY(item.open);
const close = priceToY(item.close);
const high = priceToY(item.high);
const low = priceToY(item.low);
if (x + candleWidth >= yAxisWidth && x <= width) {
ctx.fillStyle = item.close >= item.open ? 'green' : 'red';
ctx.strokeStyle = item.close >= item.open ? 'green' : 'red';
ctx.beginPath();
ctx.moveTo(x + candleWidth / 2, high);
ctx.lineTo(x + candleWidth / 2, low);
ctx.stroke();
ctx.fillRect(x, Math.min(open, close), candleWidth, Math.abs(open - close));
}
});
// Draw X-axis labels (Dates)
ctx.font = '10px Arial';
ctx.fillStyle = '#666';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
visibleData.forEach((item, index) => {
const x = yAxisWidth + xPadding + (index * (candleWidth + 2 * xPadding)) + panX + candleWidth / 2;
const monthDay = item.date.slice(5);
if (x >= yAxisWidth && x <= width) {
if (index % 2 === 0) {
ctx.fillText(monthDay, x, usableHeight + xAxisHeight / 2 + 5);
}
}
});
}
function resizeCanvas() {
const container = document.querySelector('.chart-container');
const canvas = document.getElementById('candlestickCanvas');
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
drawCandlestickChart(canvas, ctx);
}
// Initial draw and resize handling
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Event listeners for zoom and pan
canvas.addEventListener('mousedown', (e) => {
isDragging = true;
canvas.classList.add('active');
dragStartX = e.clientX - panX;
dragStartY = e.clientY;
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault();
const deltaX = e.clientX - dragStartX;
panX = deltaX;
// Calculate number of candles that would be shifted
const candleShift = Math.round(deltaX / (canvas.width / visibleData.length * zoomLevel));
// Adjust window start and end, preventing out-of-bounds
windowStart = Math.max(0, windowStart - candleShift);
windowEnd = Math.min(data.length, windowEnd - candleShift);
// Enforce minimum and maximum visible candles
const visibleCandleCount = windowEnd - windowStart;
if (visibleCandleCount < minVisibleCandles) {
if (windowStart > 0) {
windowStart = Math.max(0, windowEnd - minVisibleCandles);
} else {
windowEnd = Math.min(data.length, windowStart + minVisibleCandles);
}
} else if (visibleCandleCount > maxVisibleCandles) {
if (windowStart > 0) {
windowStart = Math.max(0, windowEnd - maxVisibleCandles);
}
else{
windowEnd = Math.min(data.length, windowStart + maxVisibleCandles);
}
}
updateVisibleData();
drawCandlestickChart(canvas, ctx);
});
const handleMouseUp = () => {
isDragging = false;
canvas.classList.remove('active');
canvas.style.cursor = 'grab';
};
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mouseleave', handleMouseUp);
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
zoomLevel *= zoomFactor;
// Keep zoom level within reasonable bounds
zoomLevel = Math.max(0.5, Math.min(zoomLevel, 5));
const zoomCenter = e.clientX;
const visibleCandleCount = windowEnd - windowStart;
let newVisibleCandleCount = Math.max(minVisibleCandles, Math.round(visibleCandleCount / zoomFactor));
newVisibleCandleCount = Math.min(newVisibleCandleCount, maxVisibleCandles); // Enforce max candles
let center = (windowStart + windowEnd) / 2;
windowStart = Math.max(0, Math.round(center - newVisibleCandleCount / 2));
windowEnd = Math.min(data.length, windowStart + newVisibleCandleCount);
//if the window is at the start or end, adjust the other end
if (windowStart === 0) {
windowEnd = Math.min(data.length, windowStart + newVisibleCandleCount);
} else if (windowEnd === data.length) {
windowStart = Math.max(0, windowEnd - newVisibleCandleCount);
}
updateVisibleData();
drawCandlestickChart(canvas, ctx);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment