├── 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