commit ef5ba17e6d33d484465df6c12046d91920fa858a Author: Michal Pemcak Date: Tue Jul 15 17:05:35 2025 +0200 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5235b5 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# uChart + +Lightweight, canvas‑based charting library written in TypeScript, designed for real‑time data streams and small bundle size. + +![uChart screenshot](./docs/screenshot.png) + +## Features + +- 💡 **Zero external rendering deps** – plain `` +- ⚡ **Real‑time friendly** – redraws only what’s needed +- 📦 **ESM ready** – authored as native ES modules +- 📝 **TypeScript types** included (`*.d.ts`) +- 🎛️ Configurable axes, ticks, colors & cursor read‑outs + +## Installation + +```bash +npm install uchart +``` + + +## Quick strart +```html + + + + + + uChart Interactive Test + + + +
+ + + + +``` + +## Test +During development you can run a live dev server: + +```bash +npm run serve +``` diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..6a1d8ff Binary files /dev/null and b/docs/screenshot.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..a101127 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "uchart", + "version": "1.0.0", + "description": "Lightweight charting library for the browser and Node.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com//uchart.git" + }, + "keywords": ["chart", "visualization", "typescript", "esbuild"], + "type": "module", + "files": ["dist"], + "main": "./dist/uchart.js", + "module": "./dist/uchart.js", + "types": "./dist/uchart.d.ts", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/uchart.d.ts", + "import": "./dist/uchart.js", + "require": "./dist/uchart.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build:js": "esbuild src/index.ts --bundle --minify --format=esm --outfile=dist/uchart.js", + "build:types": "tsc -p tsconfig.json", + "build": "npm run clean && npm run build:js && npm run build:types", + "prepublishOnly": "npm run build", + "serve": "npx http-server . -c-1 --gzip" + }, + "dependencies": { + "axios": "^1.5.0" + }, + "devDependencies": { + "esbuild": "^0.25.5", + "typescript": "^5.0.0" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100755 index 0000000..f74e303 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,293 @@ +export type ChartSeries = { + data: [number, number][]; + strokeColor?: string; +}; + +export function drawChart( + ctx: CanvasRenderingContext2D, + seriesList: ChartSeries[], + opts: { + width: number; + height: number; + min?: number; + max?: number; + showYAxis?: boolean; + yTicks?: number; + backgroundColor?: string; + cursorX?: number | null; + } +): void { + const { + width, + height, + min, + max, + showYAxis = false, + yTicks = 5, + cursorX = null + } = opts; + + const topPadding = 10; + const bottomPadding = 10; + const usableHeight = height - topPadding - bottomPadding; + + const allY = seriesList.flatMap((s) => s.data.map((d) => d[1])); + const allX = seriesList.flatMap((s) => s.data.map((d) => d[0])); + const minVal = min !== undefined ? min : Math.min(...allY); + const maxVal = max !== undefined ? max : Math.max(...allY); + const rangeY = maxVal - minVal || 1; + const minX = Math.min(...allX); + const maxX = Math.max(...allX); + const rangeX = maxX - minX || 1; + + ctx.clearRect(0, 0, width, height); + + if (showYAxis) { + ctx.beginPath(); + ctx.strokeStyle = '#aaa'; + ctx.lineWidth = 1; + ctx.font = '15px sans-serif'; + ctx.fillStyle = seriesList[0]?.strokeColor || '#000'; + for (let i = 0; i <= yTicks; i++) { + const value = minVal + (rangeY * i) / yTicks; + const y = topPadding + (1 - (value - minVal) / rangeY) * usableHeight; + ctx.moveTo(0, y); + ctx.lineTo(5, y); + ctx.fillText(value.toFixed(2), 8, y + 5); + } + ctx.stroke(); + } + + for (const series of seriesList) { + const data = series.data; + const color = series.strokeColor || '#000'; + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + + let penDown = false; + let lastT: number | null = null; + const MAX_TIME_DELTA = 600; + + for (let i = 0; i < data.length; i++) { + const [ti, vi] = data[i]; + const invalid = + ti == null || + vi == null || + isNaN(ti) || + isNaN(vi) || + !isFinite(ti) || + !isFinite(vi) || + vi < minVal || + vi > maxVal; + if (invalid) { + penDown = false; + lastT = null; + continue; + } + if (lastT != null && ti - lastT > MAX_TIME_DELTA) penDown = false; + const x = ((ti - minX) / rangeX) * width; + const y = topPadding + (1 - (vi - minVal) / rangeY) * usableHeight; + if (!penDown) { + ctx.moveTo(x, y); + penDown = true; + } else { + ctx.lineTo(x, y); + } + lastT = ti; + } + ctx.stroke(); + } + + if (cursorX !== null && cursorX >= 0 && cursorX <= width) { + const tAtCursor = minX + (cursorX / width) * rangeX; + ctx.beginPath(); + ctx.strokeStyle = '#888'; + ctx.lineWidth = 1; + ctx.moveTo(cursorX, 0); + ctx.lineTo(cursorX, height); + ctx.stroke(); + + ctx.font = '12px sans-serif'; + let textY = 15; + ctx.fillStyle = '#fff'; + ctx.fillText(new Date(tAtCursor).toLocaleTimeString(), cursorX + 6, textY); + textY += 15; + + for (const series of seriesList) { + const { data, strokeColor = '#fff' } = series; + let nearest = data[0]; + let dist = Math.abs(nearest[0] - tAtCursor); + for (let i = 1; i < data.length; i++) { + const d = Math.abs(data[i][0] - tAtCursor); + if (d < dist) { + dist = d; + nearest = data[i]; + } + } + ctx.fillStyle = strokeColor; + ctx.fillText(nearest[1].toFixed(2), cursorX + 6, textY); + textY += 15; + } + } +} + +export function createChartElement( + seriesList: ChartSeries[], + opts: { + width?: number; + height?: number; + min?: number; + max?: number; + showYAxis?: boolean; + yTicks?: number; + backgroundColor?: string; + } +) { + const container = document.createElement('div'); + container.style.position = 'relative'; + container.style.width = opts.width !== undefined ? `${opts.width}px` : '100%'; + container.style.height = opts.height !== undefined ? `${opts.height}px` : '100%'; + + const baseCanvas = document.createElement('canvas'); + const overlayCanvas = document.createElement('canvas'); + + [baseCanvas, overlayCanvas].forEach((c) => { + c.style.position = 'absolute'; + c.style.top = c.style.left = '0'; + c.style.width = c.style.height = '100%'; + container.appendChild(c); + }); + overlayCanvas.style.pointerEvents = 'none'; + + const baseCtx = baseCanvas.getContext('2d')!; + const overlayCtx = overlayCanvas.getContext('2d')!; + + let currentSeries = seriesList; + let currentOpts = { ...opts }; + let currentCursor: number | null = null; + let rafPending = false; + + const resize = () => { + const w = container.clientWidth; + const h = container.clientHeight; + if (!w || !h) return; + if (baseCanvas.width === w && baseCanvas.height === h) return; + baseCanvas.width = w; + baseCanvas.height = h; + overlayCanvas.width = w; + overlayCanvas.height = h; + drawBase(); + drawOverlay(currentCursor); + }; + + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; + ro?.observe(container); + window.addEventListener('resize', resize); + + const drawBase = () => { + drawChart(baseCtx, currentSeries, { + ...currentOpts, + width: baseCanvas.width, + height: baseCanvas.height + }); + }; + + const drawOverlay = (cursorX: number | null) => { + overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); + if (cursorX == null) return; + + const allY = currentSeries.flatMap(s => s.data.map(d => d[1])); + const allX = currentSeries.flatMap(s => s.data.map(d => d[0])); + const minVal = currentOpts.min ?? Math.min(...allY); + const maxVal = currentOpts.max ?? Math.max(...allY); + const rangeY = maxVal - minVal || 1; + const minX = Math.min(...allX); + const maxX = Math.max(...allX); + const rangeX = maxX - minX || 1; + + const tAtCursor = minX + (cursorX / baseCanvas.width) * rangeX; + + overlayCtx.beginPath(); + overlayCtx.strokeStyle = '#888'; + overlayCtx.lineWidth = 1; + overlayCtx.moveTo(cursorX, 0); + overlayCtx.lineTo(cursorX, overlayCanvas.height); + overlayCtx.stroke(); + + overlayCtx.font = '12px sans-serif'; + overlayCtx.fillStyle = '#fff'; + overlayCtx.fillText(new Date(tAtCursor).toLocaleTimeString(), cursorX > baseCanvas.width - 100 ? cursorX - 100 : cursorX + 6, 15); + + const topPadding = 10; + const bottomPadding = 10; + const usableHeight = overlayCanvas.height - topPadding - bottomPadding; + let textY = 30; + + for (const { data, strokeColor = '#fff' } of currentSeries) { + let nearest = data[0]; + let dist = Math.abs(nearest[0] - tAtCursor); + for (let i = 1; i < data.length; i++) { + const d = Math.abs(data[i][0] - tAtCursor); + if (d < dist) { + dist = d; + nearest = data[i]; + } + } + overlayCtx.fillStyle = strokeColor; + overlayCtx.fillText(nearest[1].toFixed(2),cursorX > baseCanvas.width - 100 ? cursorX - 100 : cursorX + 6, textY); + textY += 15; + + const y = topPadding + (1 - (nearest[1] - minVal) / rangeY) * usableHeight; + overlayCtx.beginPath(); + overlayCtx.arc(cursorX, y, 3, 0, Math.PI * 2); + overlayCtx.fill(); + } + }; + + const handleMouseMove = (e: MouseEvent) => { + const rect = container.getBoundingClientRect(); + const scaleX = baseCanvas.width / rect.width; + currentCursor = (e.clientX - rect.left) * scaleX; + if (!rafPending) { + rafPending = true; + requestAnimationFrame(() => { + drawOverlay(currentCursor); + rafPending = false; + }); + } + }; + + const handleMouseLeave = () => { + currentCursor = null; + drawOverlay(null); + }; + + container.addEventListener('mousemove', handleMouseMove); + container.addEventListener('mouseleave', handleMouseLeave); + + setTimeout(resize, 0); + + return { + element: container, + setSeries(series: ChartSeries[]) { + currentSeries = series; + resize(); + drawBase(); + drawOverlay(currentCursor); + }, + setOptions(o: Partial) { + currentOpts = { ...currentOpts, ...o }; + resize(); + drawBase(); + drawOverlay(currentCursor); + }, + destroy() { + ro?.disconnect(); + window.removeEventListener('resize', resize); + container.removeEventListener('mousemove', handleMouseMove); + container.removeEventListener('mouseleave', handleMouseLeave); + } + }; +} + diff --git a/test.html b/test.html new file mode 100644 index 0000000..8be8b45 --- /dev/null +++ b/test.html @@ -0,0 +1,48 @@ + + + + + + uChart Interactive Test + + + +
+ + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fd5a37e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "ES6", + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +}