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