├── chart.js
├── demo.html
├── graphics.js
├── math.js
└── style.css
/chart.js:
--------------------------------------------------------------------------------
1 | class Chart{
2 | constructor(container,samples,options,onClick=null){
3 | this.samples=samples;
4 |
5 | this.axesLabels=options.axesLabels;
6 | this.styles=options.styles;
7 | this.icon=options.icon;
8 | this.onClick=onClick;
9 |
10 | this.canvas=document.createElement("canvas");
11 | this.canvas.width=options.size;
12 | this.canvas.height=options.size;
13 | this.canvas.style="background-color:white;";
14 | container.appendChild(this.canvas);
15 |
16 | this.ctx=this.canvas.getContext("2d");
17 |
18 | this.margin=options.size*0.11;
19 | this.transparency=options.transparency||1;
20 |
21 | this.dataTrans={
22 | offset:[0,0],
23 | scale:1
24 | };
25 | this.dragInfo={
26 | start:[0,0],
27 | end:[0,0],
28 | offset:[0,0],
29 | dragging:false
30 | };
31 |
32 | this.hoveredSample=null;
33 | this.selectedSample=null;
34 |
35 | this.pixelBounds=this.#getPixelBounds();
36 | this.dataBounds=this.#getDataBounds();
37 | this.defaultDataBounds=this.#getDataBounds();
38 |
39 | this.#draw();
40 |
41 | this.#addEventListeners();
42 | }
43 |
44 | #addEventListeners(){
45 | const {canvas,dataTrans,dragInfo}=this;
46 | canvas.onmousedown=(evt)=>{
47 | const dataLoc=this.#getMouse(evt,true);
48 | dragInfo.start=dataLoc;
49 | dragInfo.dragging=true;
50 | dragInfo.end=[0,0];
51 | dragInfo.offset=[0,0];
52 | }
53 | canvas.onmousemove=(evt)=>{
54 | if(dragInfo.dragging){
55 | const dataLoc=this.#getMouse(evt,true);
56 | dragInfo.end=dataLoc;
57 | dragInfo.offset=math.scale(
58 | math.subtract(
59 | dragInfo.start,dragInfo.end
60 | ),
61 | dataTrans.scale**2
62 | );
63 | const newOffset=math.add(
64 | dataTrans.offset,
65 | dragInfo.offset
66 | );
67 | this.#updateDataBounds(
68 | newOffset,
69 | dataTrans.scale
70 | );
71 | }
72 | const pLoc=this.#getMouse(evt);
73 | const pPoints=this.samples.map(s=>
74 | math.remapPoint(
75 | this.dataBounds,
76 | this.pixelBounds,
77 | s.point
78 | )
79 | );
80 | const index=math.getNearest(pLoc,pPoints);
81 | const nearest=this.samples[index];
82 | const dist=math.distance(pPoints[index],pLoc);
83 | if(dist{
92 | dataTrans.offset=math.add(
93 | dataTrans.offset,
94 | dragInfo.offset
95 | );
96 | dragInfo.dragging=false;
97 | }
98 | canvas.onwheel=(evt)=>{
99 | const dir=Math.sign(evt.deltaY);
100 | const step=0.02;
101 | dataTrans.scale+=dir*step;
102 | dataTrans.scale=Math.max(step,
103 | Math.min(2,dataTrans.scale)
104 | );
105 |
106 | this.#updateDataBounds(
107 | dataTrans.offset,
108 | dataTrans.scale
109 | );
110 |
111 | this.#draw();
112 | evt.preventDefault();
113 | }
114 | canvas.onclick=()=>{
115 | if(!math.equals(dragInfo.offset,[0,0])){
116 | return;
117 | }
118 | if(this.hoveredSample){
119 | if(this.selectedSample==this.hoveredSample){
120 | this.selectedSample=null;
121 | }else{
122 | this.selectedSample=this.hoveredSample;
123 | }
124 | }else{
125 | this.selectedSample=null;
126 | }
127 | if(this.onClick){
128 | this.onClick(
129 | this.selectedSample
130 | );
131 | }
132 | this.#draw();
133 | }
134 | }
135 |
136 | #updateDataBounds(offset,scale){
137 | const {dataBounds,defaultDataBounds:def}=this;
138 | dataBounds.left=def.left+offset[0];
139 | dataBounds.right=def.right+offset[0];
140 | dataBounds.top=def.top+offset[1];
141 | dataBounds.bottom=def.bottom+offset[1];
142 |
143 | const center=[
144 | (dataBounds.left+dataBounds.right)/2,
145 | (dataBounds.top+dataBounds.bottom)/2
146 | ];
147 |
148 | dataBounds.left=math.lerp(
149 | center[0],
150 | dataBounds.left,
151 | scale**2
152 | );
153 |
154 | dataBounds.right=math.lerp(
155 | center[0],
156 | dataBounds.right,
157 | scale**2
158 | );
159 |
160 | dataBounds.top=math.lerp(
161 | center[1],
162 | dataBounds.top,
163 | scale**2
164 | );
165 |
166 | dataBounds.bottom=math.lerp(
167 | center[1],
168 | dataBounds.bottom,
169 | scale**2
170 | );
171 | }
172 |
173 | #getMouse=(evt,dataSpace=false)=>{
174 | const rect=this.canvas.getBoundingClientRect();
175 | const pixelLoc=[
176 | evt.clientX-rect.left,
177 | evt.clientY-rect.top
178 | ];
179 | if(dataSpace){
180 | const dataLoc=math.remapPoint(
181 | this.pixelBounds,
182 | this.defaultDataBounds,
183 | pixelLoc
184 | );
185 | return dataLoc;
186 | }
187 | return pixelLoc;
188 | }
189 |
190 | #getPixelBounds(){
191 | const {canvas,margin}=this;
192 | const bounds={
193 | left:margin,
194 | right:canvas.width-margin,
195 | top:margin,
196 | bottom:canvas.height-margin
197 | };
198 | return bounds;
199 | }
200 |
201 | #getDataBounds(){
202 | const {samples}=this;
203 | const x=samples.map(s=>s.point[0]);
204 | const y=samples.map(s=>s.point[1]);
205 | const minX=Math.min(...x);
206 | const maxX=Math.max(...x);
207 | const minY=Math.min(...y);
208 | const maxY=Math.max(...y);
209 | const bounds={
210 | left:minX,
211 | right:maxX,
212 | top:maxY,
213 | bottom:minY
214 | };
215 | return bounds;
216 | }
217 |
218 | #draw(){
219 | const {ctx,canvas}=this;
220 | ctx.clearRect(0,0,canvas.width,canvas.height);
221 |
222 | ctx.globalAlpha=this.transparency;
223 | this.#drawSamples(this.samples);
224 | ctx.globalAlpha=1;
225 |
226 | if(this.hoveredSample){
227 | this.#emphasizeSample(
228 | this.hoveredSample
229 | );
230 | }
231 |
232 | if(this.selectedSample){
233 | this.#emphasizeSample(
234 | this.selectedSample,"yellow"
235 | );
236 | }
237 |
238 | this.#drawAxes();
239 | }
240 |
241 | selectSample(sample){
242 | this.selectedSample=sample;
243 | this.#draw();
244 | }
245 |
246 | #emphasizeSample(sample,color="white"){
247 | const pLoc=math.remapPoint(
248 | this.dataBounds,
249 | this.pixelBounds,
250 | sample.point
251 | );
252 | const grd=this.ctx.createRadialGradient(
253 | ...pLoc,0,...pLoc,this.margin
254 | );
255 | grd.addColorStop(0,color);
256 | grd.addColorStop(1,"rgba(255,255,255,0)");
257 | graphics.drawPoint(
258 | this.ctx,pLoc,grd,this.margin*2
259 | );
260 | this.#drawSamples(
261 | [sample]
262 | );
263 | }
264 |
265 | #drawAxes(){
266 | const {ctx,canvas,axesLabels,margin}=this;
267 | const {left,right,top,bottom}=this.pixelBounds;
268 |
269 | ctx.clearRect(0,0,this.canvas.width,margin);
270 | ctx.clearRect(0,0,margin,this.canvas.height);
271 | ctx.clearRect(this.canvas.width-margin,0,
272 | margin,this.canvas.height
273 | );
274 | ctx.clearRect(0,this.canvas.height-margin,
275 | this.canvas.width,margin
276 | );
277 |
278 | graphics.drawText(ctx,{
279 | text:axesLabels[0],
280 | loc:[canvas.width/2,bottom+margin/2],
281 | size:margin*0.6
282 | });
283 |
284 | ctx.save();
285 | ctx.translate(left-margin/2,canvas.height/2);
286 | ctx.rotate(-Math.PI/2);
287 | graphics.drawText(ctx,{
288 | text:axesLabels[1],
289 | loc:[0,0],
290 | size:margin*0.6
291 | });
292 | ctx.restore();
293 |
294 | ctx.beginPath();
295 | ctx.moveTo(left,top);
296 | ctx.lineTo(left,bottom);
297 | ctx.lineTo(right,bottom);
298 | ctx.setLineDash([5,4]);
299 | ctx.lineWidth=2;
300 | ctx.strokeStyle="lightgray";
301 | ctx.stroke();
302 | ctx.setLineDash([]);
303 |
304 | const dataMin=math.remapPoint(
305 | this.pixelBounds,
306 | this.dataBounds,
307 | [left,bottom]
308 | );
309 | graphics.drawText(ctx,{
310 | text:math.formatNumber(dataMin[0],2),
311 | loc:[left,bottom],
312 | size:margin*0.3,
313 | align:"left",
314 | vAlign:"top"
315 | });
316 | ctx.save();
317 | ctx.translate(left,bottom);
318 | ctx.rotate(-Math.PI/2);
319 | graphics.drawText(ctx,{
320 | text:math.formatNumber(dataMin[1],2),
321 | loc:[0,0],
322 | size:margin*0.3,
323 | align:"left",
324 | vAlign:"bottom"
325 | });
326 | ctx.restore();
327 |
328 | const dataMax=math.remapPoint(
329 | this.pixelBounds,
330 | this.dataBounds,
331 | [right,top]
332 | );
333 | graphics.drawText(ctx,{
334 | text:math.formatNumber(dataMax[0],2),
335 | loc:[right,bottom],
336 | size:margin*0.3,
337 | align:"right",
338 | vAlign:"top"
339 | });
340 | ctx.save();
341 | ctx.translate(left,top);
342 | ctx.rotate(-Math.PI/2);
343 | graphics.drawText(ctx,{
344 | text:math.formatNumber(dataMax[1],2),
345 | loc:[0,0],
346 | size:margin*0.3,
347 | align:"right",
348 | vAlign:"bottom"
349 | });
350 | ctx.restore();
351 | }
352 |
353 | #drawSamples(samples){
354 | const {ctx,dataBounds,pixelBounds}=this;
355 | for(const sample of samples){
356 | const {point,label}=sample;
357 | const pixelLoc=math.remapPoint(
358 | dataBounds,pixelBounds,point
359 | );
360 | switch(this.icon){
361 | case "image":
362 | graphics.drawImage(ctx,
363 | this.styles[label].image,
364 | pixelLoc
365 | );
366 | break;
367 | case "text":
368 | graphics.drawText(ctx,{
369 | text:this.styles[label].text,
370 | loc:pixelLoc,
371 | size:20
372 | });
373 | break;
374 | default:
375 | graphics.drawPoint(ctx,pixelLoc,
376 | this.styles[label].color);
377 | break;
378 | }
379 | }
380 | }
381 | }
--------------------------------------------------------------------------------
/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Custom Chart
5 |
6 |
7 |
8 | Custom Chart
9 |
10 |
11 |
12 |
13 |
14 |
101 |
102 |
--------------------------------------------------------------------------------
/graphics.js:
--------------------------------------------------------------------------------
1 | const graphics={};
2 |
3 | graphics.drawPoint=(ctx,loc,color="black",size=8)=>{
4 | ctx.beginPath();
5 | ctx.fillStyle=color;
6 | ctx.arc(...loc,size/2,0,Math.PI*2);
7 | ctx.fill();
8 | }
9 |
10 | graphics.drawText=(ctx,
11 | {text,loc,align="center",vAlign="middle",size=10,color="black"})=>{
12 | ctx.textAlign=align;
13 | ctx.textBaseline=vAlign;
14 | ctx.font="bold "+size+"px Courier";
15 | ctx.fillStyle=color;
16 | ctx.fillText(text,...loc);
17 | }
18 |
19 | graphics.generateImages=(styles,size=20)=>{
20 | for(let label in styles){
21 | const style=styles[label];
22 | const canvas=document.createElement("canvas");
23 | canvas.width=size+10;
24 | canvas.height=size+10;
25 |
26 | const ctx=canvas.getContext("2d");
27 | ctx.beginPath();
28 | ctx.textAlign="center";
29 | ctx.textBaseline="middle";
30 | ctx.font=size+"px Courier";
31 |
32 | const colorHueMap={
33 | red:0,
34 | yellow:60,
35 | green:120,
36 | cyan:180,
37 | blue:240,
38 | magenta:300
39 | };
40 | const hue=-45+colorHueMap[style.color];
41 | if(!isNaN(hue)){
42 | ctx.filter=`
43 | brightness(2)
44 | contrast(0.3)
45 | sepia(1)
46 | brightness(0.7)
47 | hue-rotate(${hue}deg)
48 | saturate(3)
49 | contrast(3)
50 | `;
51 | }else{
52 | ctx.filter="grayscale(1)";
53 | }
54 |
55 | ctx.fillText(style.text,
56 | canvas.width/2,canvas.height/2);
57 |
58 | style["image"]=new Image();
59 | style["image"].src=canvas.toDataURL();
60 | }
61 | }
62 |
63 | graphics.drawImage=(ctx,image,loc)=>{
64 | ctx.beginPath();
65 | ctx.drawImage(image,
66 | loc[0]-image.width/2,
67 | loc[1]-image.height/2,
68 | image.width,
69 | image.height
70 | );
71 | ctx.fill();
72 | }
--------------------------------------------------------------------------------
/math.js:
--------------------------------------------------------------------------------
1 | const math={};
2 |
3 | math.equals=(p1,p2)=>{
4 | return p1[0]==p2[0]&&p1[1]==p2[1];
5 | }
6 |
7 | math.lerp=(a,b,t)=>{
8 | return a+(b-a)*t;
9 | }
10 |
11 | math.invLerp=(a,b,v)=>{
12 | return (v-a)/(b-a);
13 | }
14 |
15 | math.remap=(oldA,oldB,newA,newB,v)=>{
16 | return math.lerp(newA,newB,math.invLerp(oldA,oldB,v));
17 | }
18 |
19 | math.remapPoint=(oldBounds,newBounds,point)=>{
20 | return [
21 | math.remap(oldBounds.left,oldBounds.right,
22 | newBounds.left,newBounds.right,point[0]),
23 | math.remap(oldBounds.top,oldBounds.bottom,
24 | newBounds.top,newBounds.bottom,point[1])
25 | ];
26 | }
27 |
28 | math.add=(p1,p2)=>{
29 | return[
30 | p1[0]+p2[0],
31 | p1[1]+p2[1]
32 | ];
33 | }
34 |
35 | math.subtract=(p1,p2)=>{
36 | return[
37 | p1[0]-p2[0],
38 | p1[1]-p2[1]
39 | ];
40 | }
41 |
42 | math.scale=(p,scaler)=>{
43 | return[
44 | p[0]*scaler,
45 | p[1]*scaler
46 | ];
47 | }
48 |
49 | math.distance=(p1,p2)=>{
50 | return Math.sqrt(
51 | (p1[0]-p2[0])**2+
52 | (p1[1]-p2[1])**2
53 | );
54 | }
55 |
56 | math.formatNumber=(n,dec=0)=>{
57 | return n.toFixed(dec);
58 | }
59 |
60 | math.getNearest=(loc,points)=>{
61 | let minDist=Number.MAX_SAFE_INTEGER;
62 | let nearestIndex=0;
63 |
64 | for(let i=0;i