Created
March 20, 2025 07:17
-
-
Save esshka/318cdabc00b3decf6ec250c421ef9b04 to your computer and use it in GitHub Desktop.
simple candlesticks price chart on canvas
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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