Initial commit
This commit is contained in:
76
README.md
Normal file
76
README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# uChart
|
||||
|
||||
Lightweight, canvas‑based charting library written in TypeScript, designed for real‑time data streams and small bundle size.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 💡 **Zero external rendering deps** – plain `<canvas>`
|
||||
- ⚡ **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
|
||||
<!-- test.html (included in the repo) -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>uChart Interactive Test</title>
|
||||
<style>
|
||||
#chartContainer{width:1000px;height:400px;margin:20px auto;background:#111}
|
||||
body{background:#222}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chartContainer"></div>
|
||||
|
||||
<script type="module">
|
||||
import { createChartElement } from './dist/uchart.js';
|
||||
|
||||
const container = document.getElementById('chartContainer');
|
||||
const chart = createChartElement([], { showYAxis:true, yTicks:5 });
|
||||
container.appendChild(chart.element);
|
||||
|
||||
let phase = 0;
|
||||
|
||||
function sine(len, shift, amp, off, f=1){
|
||||
const now=Date.now(), d=[];
|
||||
for(let i=0;i<len;i++){
|
||||
const t=now+i*10, x=i/len*2*Math.PI*f, y=Math.sin(x+shift)*amp+off;
|
||||
d.push([t,y]);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function update(){
|
||||
chart.setSeries([
|
||||
{ data: sine(1500, phase, 20, 10, 1), strokeColor:'#0f0' },
|
||||
{ data: sine(1500, phase, 20, 10, 2), strokeColor:'#f00' },
|
||||
{ data: sine(1500, phase, 20, 10, 0.5), strokeColor:'#00f' }
|
||||
]);
|
||||
phase += 0.01;
|
||||
}
|
||||
update();
|
||||
setInterval(update, 50);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Test
|
||||
During development you can run a live dev server:
|
||||
|
||||
```bash
|
||||
npm run serve
|
||||
```
|
||||
BIN
docs/screenshot.png
Normal file
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
39
package.json
Normal file
39
package.json
Normal file
@@ -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/<user>/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"
|
||||
}
|
||||
}
|
||||
293
src/index.ts
Executable file
293
src/index.ts
Executable file
@@ -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<typeof opts>) {
|
||||
currentOpts = { ...currentOpts, ...o };
|
||||
resize();
|
||||
drawBase();
|
||||
drawOverlay(currentCursor);
|
||||
},
|
||||
destroy() {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener('resize', resize);
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
48
test.html
Normal file
48
test.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!-- test.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>uChart Interactive Test</title>
|
||||
<style>
|
||||
#chartContainer { width: 1000px; height: 400px; margin: 20px auto; background:#111; }
|
||||
body { background:#222; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chartContainer"></div>
|
||||
|
||||
<script type="module">
|
||||
import { createChartElement } from './dist/uchart.js';
|
||||
|
||||
const container = document.getElementById('chartContainer');
|
||||
const chart = createChartElement([], { showYAxis:true, yTicks:5 });
|
||||
container.appendChild(chart.element);
|
||||
|
||||
let phase = 0;
|
||||
|
||||
function generateSineSeries(len, shift, amp, off, freq = 1){
|
||||
const now = Date.now();
|
||||
const data=[];
|
||||
for(let i=0;i<len;i++){
|
||||
const t = now + i*10;
|
||||
const x = i/len*2*Math.PI*freq;
|
||||
const y = Math.sin(x+shift)*amp + off;
|
||||
data.push([t,y]);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function update(){
|
||||
chart.setSeries([
|
||||
{ data: generateSineSeries(1500, phase, 20, 10, 1), strokeColor:'#0f0' },
|
||||
{ data: generateSineSeries(1500, phase, 20, 10, 2), strokeColor:'#f00' },
|
||||
{ data: generateSineSeries(1500, phase, 20, 10, 0.5), strokeColor:'#00f' }
|
||||
]);
|
||||
phase += 0.01;
|
||||
}
|
||||
update()
|
||||
setInterval(update, 50);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "ES6",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user