Passed
Push — main ( 976540...90c965 )
by Dylan
02:58
created

src/jrid.tsx   A

Complexity

Total Complexity 41
Complexity/F 0

Size

Lines of Code 588
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Test Coverage

Coverage 14.61%

Importance

Changes 0
Metric Value
wmc 41
eloc 480
mnd 41
bc 41
fnc 0
dl 0
loc 588
ccs 45
cts 308
cp 0.1461
rs 9.1199
bpm 0
cpm 0
noi 0
c 0
b 0
f 0

How to fix   Complexity   

Complexity

Complex classes like src/jrid.tsx often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import VERSION from './version'
2
import { createRef, FunctionalComponent, h, render } from 'preact'
3
import { useEffect } from 'preact/hooks'
4
import Canvas, { CanvasMethods } from './canvas'
5
// import style from './style.css'
6
7 3
const logBase = 10
8 3
const zoomFactor = logBase**(1/13)
9 3
const microZoomFactor = zoomFactor**(1/32)
10 3
const minimumJridSpacing = 24
11 3
const μ = .9
12 3
const translateFactor = 16
13 3
let free = true
14
15 3
export const axisLabelFormat = (coefficient: number, exponent: number): string => {
16 2
  if (coefficient === 0) return '0'
17
  // simple notation for small exponents
18 2
  if (exponent < 5) {
19
    // @todo i18n eg. toLocaleString
20 2
    if (exponent >= 0) {
21
      // 1...10,000
22
      return `${coefficient}${'0'.repeat(exponent)}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
23
    }
24 2
    if (exponent > -5) {
25
      // 0...0.0001
26
      return (coefficient*logBase**exponent).toFixed(-exponent)
27
    }
28
  }
29
  // pseudoscientific notation, eg. 5×⏨42 for 5*10^42
30
  return `${coefficient}×⏨${exponent}`
31
}
32
33
type TranslateFunction = (x: number, y: number) => void
34
35
type ScaleFunction = (x: number, y: number) => void
36
37
export interface JridOverlayProps {
38
  canvasMethodRefs?: CanvasMethods;
39
  setTranslate?: TranslateFunction;
40
  setScale?: ScaleFunction;
41
  initialScale?: number;
42
}
43
44 3
export const JridOverlay: FunctionalComponent<JridOverlayProps> = (props: JridOverlayProps) => {
45
  const { setTranslate, setScale, ...rest } = props
46
  const ref = createRef()
47
  const canvasCenter = [0, 0]
48
  const translate = [0, 0]
49
  const scale = [1, 1]
50
  const velocity = [0, 0]
51
  const fontSize = 12
52
  const axisLabelMargin = 4
53
  // const xLabelOffset = [-axisLabelMargin, fontSize+axisLabelMargin] // incorrect with rotation
54
  const xLabelOffset = [-2, 10]
55
  const yLabelOffset = [-axisLabelMargin, -axisLabelMargin]
56
  let contextHeight = 0
57
58
  const init = (ctx: CanvasRenderingContext2D): void => {
59 2
    const initialScale = rest.initialScale ?? 16/ctx.canvas.width
60
    scale[0] = scale[1] = initialScale
61 2
    if (setScale) {
62
      setScale(scale[0], scale[1])
63
    }
64
    draw(ctx)
65
  }
66
67
  const onResize = (ctx: CanvasRenderingContext2D): void => {
68
69
    // line styles
70
    ctx.strokeStyle = '#999'
71
    ctx.lineWidth = 1
72
    
73
    // text styles
74
    ctx.fillStyle = '#999'
75
    ctx.textAlign = 'right'
76
    ctx.font = `${fontSize}px monospace`
77
    // @todo white on white bg in unreadable
78
    // shadowBlur works ok but is too slow in Firefox
79
    // ctx.shadowColor = 'rgba(0,0,0,1)'
80
    // ctx.shadowBlur = 4
81
82
    contextHeight = ctx.canvas.height
83
    const halfWidth = ctx.canvas.width/2
84
    const halfHeight = ctx.canvas.height/2
85 2
    if (canvasCenter[0] === 0) {
86
      // initially translate (0,0) to center of canvas
87
      canvasCenter[0] = halfWidth
88
      canvasCenter[1] = halfHeight
89
      translate[0] = -canvasCenter[0]
90
      translate[1] = -canvasCenter[1]
91
    }
92
    else {
93
      // adjust translation by difference in canvas size
94
      const dx = halfWidth - canvasCenter[0]
95
      const dy = halfHeight - canvasCenter[1]
96
      translate[0] -= dx
97
      translate[1] -= dy
98
      canvasCenter[0] = halfWidth
99
      canvasCenter[1] = halfHeight
100
    }
101
    draw(ctx)
102
  }
103
104
  const render = (): void => {
105
    void(0)
106
  }
107
108
  const draw = (ctx: CanvasRenderingContext2D): void => {
109
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
110
111
    // slow down due to friction
112
    velocity[0] *= μ
113
    velocity[1] *= μ
114
115
    // glide
116 2
    if (free) {
117
      translate[0] += velocity[0]
118
      translate[1] -= velocity[1]
119
    }
120
    
121
    // exponent for axis labels ⏨(n+x)
122
    const powerX = Math.ceil(Math.log10(minimumJridSpacing * scale[0]))
123
    const powerY = Math.ceil(Math.log10(minimumJridSpacing * scale[1]))
124
    const factorX = 10**powerX
125
    const factorY = 10**powerY
126
127
    // set space between lines
128
    const spaceX = factorX / scale[0]
129
    const spaceY = factorY / scale[1]
130
131
    // get first lines by rounding up
132
    const xIndexOffset = Math.ceil(translate[0] * scale[0] / factorX)
133
    const yIndexOffset = Math.ceil(translate[1] * scale[1] / factorY) 
134
    const firstXValue = xIndexOffset * factorX
135
    const firstYValue = yIndexOffset * factorY
136
    const firstXPosition = firstXValue / scale[0] - translate[0]
137
    const firstYPosition = translate[1] - firstYValue / scale[1]
138
    
139
    // lines to write labels on
140
    const xLineCount = Math.floor(ctx.canvas.width / spaceX)
141
    const yLineCount = Math.floor(ctx.canvas.height / spaceY)
142
    const xMiddleLineIndex = Math.floor(xLineCount / 2)
143
    const yMiddleLineIndex = Math.floor(yLineCount / 2)
144
145
    ctx.beginPath()
146
    // draw x-axis Jrid lines
147
    for (let i=0; i<=xLineCount; i++) {
148
      const x = firstXPosition + i * spaceX
149
      ctx.moveTo(x, 0)
150
      ctx.lineTo(x, ctx.canvas.height)
151
      // draw y-axis labels up the middle line
152 2
      if (i === xMiddleLineIndex) {
153
        for (let j=0; j<=yLineCount; j++) {
154
          const label = axisLabelFormat(j + yIndexOffset, powerY)
155
          const y = firstYPosition + ctx.canvas.height - j * spaceY
156
          ctx.fillText(label, x + yLabelOffset[0], y + yLabelOffset[1])
157
        }
158
      }
159
    }
160
    // draw y-axis Jrid lines
161
    for (let i=0; i<=yLineCount; i++) {
162
      const y = firstYPosition + ctx.canvas.height - i * spaceY
163
      ctx.moveTo(0, y)
164
      ctx.lineTo(ctx.canvas.width, y)
165
      // draw x-axis labels below the middle line
166 2
      if (i === yMiddleLineIndex) {
167
        for (let j=0; j<=xLineCount; j++) {
168
          const label = axisLabelFormat(j + xIndexOffset, powerX)
169
          const x = firstXPosition + j * spaceX
170
171
          // rotate: to avoid overlap with long labels and small Jrid
172
          // @todo better to rotate once then draw all labels?
173
          ctx.save()
174
          ctx.translate(x + xLabelOffset[0], y + xLabelOffset[1])
175
          ctx.rotate(-Math.PI/6)
176
          ctx.fillText(label, 0, 0)
177
          ctx.restore()
178
179
          // without rotate:
180
          // ctx.fillText(label, x + xLabelOffset[0], y + xLabelOffset[1])
181
182
        }
183
      }
184
    }
185
    ctx.stroke()
186
187
    // update position of main canvas
188 2
    if (setTranslate) {
189
      setTranslate(translate[0], translate[1])
190
    }
191
  }
192
193
  useEffect(() => {
194
    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
195
    const canvasEl = ref.current.base as HTMLCanvasElement
196
    let mouseDown = 0
197
    let renderCallbackID: number
198
    const lastMousePosition = [0, 0]
199
    const lastTouch1Position = [-1, -1]
200
    const lastTouch2Position = [-1, -1]
201
202
    const handleMouseDown = (event: MouseEvent): boolean => {
203
      mouseDown |= (1<<event.button)
204
      lastMousePosition[0] = event.clientX
205
      lastMousePosition[1] = event.clientY
206
      velocity[0] = velocity[1] = 0
207
      free = !(mouseDown&1)
208
      event.preventDefault()
209
      return false
210
    }
211
    canvasEl.addEventListener('mousedown', handleMouseDown)
212
213
    const handleMouseUp = (event: MouseEvent): boolean => {
214
      mouseDown ^= (1<<event.button)
215
      event.preventDefault()
216
      free = !(mouseDown&1)
217
      return false
218
    }
219
    canvasEl.addEventListener('mouseup', handleMouseUp)
220
221
    const handleContextMenu = (event: MouseEvent): boolean => {
222
      event.preventDefault()
223
      return false
224
    }
225
    canvasEl.addEventListener('contextmenu', handleContextMenu)
226
227
    const handleMouseMove = (event: MouseEvent): void => {
228
      const dx = lastMousePosition[0] - event.clientX
229
      const dy = lastMousePosition[1] - event.clientY
230
      const flingFactor = 4
231
      // left-clicked, translate
232 2
      if (mouseDown&1) {
233
        velocity[0] = dx * flingFactor
234
        velocity[1] = dy * flingFactor
235
        translate[0] += dx
236
        translate[1] -= dy
237 2
        if (setTranslate) {
238
          setTranslate(translate[0], translate[1])
239
          render()
240
        }
241
      }
242
      // middle-clicked, scale all axes
243 2
      if (mouseDown&2) {
244
        const f = microZoomFactor**-dy
245
        scale[0] *= f
246
        scale[1] *= f
247 2
        if (setScale) {
248
          setScale(scale[0], scale[1])
249
          render()
250
        } 
251
      }
252
      // right-clicked, scale individual axes
253 2
      if (mouseDown&4) {
254
        const zoomTo = [
255
          (lastMousePosition[0] + translate[0]) * scale[0],
256
          (contextHeight - lastMousePosition[1] + translate[1]) * scale[1]
257
        ]
258
        const zoomLastPosition = [
259
          zoomTo[0] / scale[0] - translate[0],
260
          zoomTo[1] / scale[1] - translate[1]
261
        ]
262
        scale[0] *= microZoomFactor**dx
263
        scale[1] *= microZoomFactor**-dy
264 2
        if (setScale) {
265
          setScale(scale[0], scale[1])
266
        }
267
        const zoomToPosition = [
268
          zoomTo[0] / scale[0] - translate[0],
269
          zoomTo[1] / scale[1] - translate[1]
270
        ]
271
        translate[0] -= zoomLastPosition[0] - zoomToPosition[0] - dx
272
        translate[1] += zoomToPosition[1] - zoomLastPosition[1] - dy
273 2
        if (setTranslate) {
274
          setTranslate(translate[0], translate[1])
275
        }
276
        render()
277
      }
278
      lastMousePosition[0] = event.clientX
279
      lastMousePosition[1] = event.clientY
280
    }
281
    canvasEl.addEventListener('mousemove', handleMouseMove)
282
283
    const handleTouchDown = (event: TouchEvent): boolean => {
284
      lastTouch1Position[0] = event.touches[0].pageX
285
      lastTouch1Position[1] = event.touches[0].pageY
286
      velocity[0] = velocity[1] = 0
287
      free = false
288
      return false
289
    }
290
    canvasEl.addEventListener('touchstart', handleTouchDown)
291
292
    const handleTouchUp = (event: TouchEvent): boolean => {
293
      void(event)
294
      lastTouch1Position[0] = lastTouch1Position[1] = -1
295
      free = true
296
      return false
297
    }
298
    canvasEl.addEventListener('touchend', handleTouchUp)
299
300
    const handleTouchMove = (event: TouchEvent): void => {
301 2
      if (lastTouch1Position[0] > -1) {
302 2
        if (event.touches.length===1) {
303
          const dx = lastTouch1Position[0] - event.touches[0].pageX
304
          const dy = lastTouch1Position[1] - event.touches[0].pageY
305
          velocity[0] = dx
306
          velocity[1] = dy
307
          translate[0] += dx
308
          translate[1] -= dy
309
          lastTouch2Position[0] = lastTouch2Position[1] = -1
310 2
          if (setTranslate) {
311
            setTranslate(translate[0], translate[1])
312
            render()
313
          }
314
        }
315
        else {
316 2
          if (lastTouch2Position[0] > -1) {
317
            const x1 = event.touches[0].pageX
318
            const y1 = event.touches[0].pageY
319
            const x2 = event.touches[1].pageX
320
            const y2 = event.touches[1].pageY
321
            const q1 = (lastTouch1Position[0] - lastTouch2Position[0])**2 + (lastTouch1Position[1] - lastTouch2Position[1])**2
322
            const q2 = (x1 - x2)**2 + (y1 - y2)**2
323
            const zoomModifier = q1 / q2
324
            const touchMidpoint = [
325
              (x1 + x2) / 2,
326
              (y1 + y2) / 2
327
            ]
328
            const zoomTo = [
329
              (touchMidpoint[0] + translate[0]) * scale[0],
330
              (contextHeight - touchMidpoint[1] + translate[1]) * scale[1]
331
            ]
332
            const zoomLastPosition = [
333
              zoomTo[0] / scale[0] - translate[0],
334
              zoomTo[1] / scale[1] - translate[1]
335
            ]
336
            scale[0] *= zoomModifier
337
            scale[1] *= zoomModifier
338 2
            if (setScale) {
339
              setScale(scale[0], scale[1])
340
            }
341
            const zoomToPosition = [
342
              zoomTo[0] / scale[0] - translate[0],
343
              zoomTo[1] / scale[1] - translate[1]
344
            ]
345
            translate[0] -= zoomLastPosition[0] - zoomToPosition[0]
346
            translate[1] += zoomToPosition[1] - zoomLastPosition[1]
347 2
            if (setTranslate) {
348
              setTranslate(translate[0], translate[1])
349
            }
350
            render()
351
          }
352
          lastTouch2Position[0] = event.touches[1].pageX
353
          lastTouch2Position[1] = event.touches[1].pageY
354
        }
355
      }
356
      lastTouch1Position[0] = event.touches[0].pageX
357
      lastTouch1Position[1] = event.touches[0].pageY
358
    }
359
    canvasEl.addEventListener('touchmove', handleTouchMove)
360
361
    const handleWheel = (event: WheelEvent): void => {
362 2
      const zoomModifier = event.deltaY > 0 ? zoomFactor : 1/zoomFactor
363
      const zoomTo = [
364
        (lastMousePosition[0] + translate[0]) * scale[0],
365
        (contextHeight - lastMousePosition[1] + translate[1]) * scale[1]
366
      ]
367
      const zoomLastPosition = [
368
        zoomTo[0] / scale[0] - translate[0],
369
        zoomTo[1] / scale[1] - translate[1]
370
      ]
371
      scale[0] *= zoomModifier
372
      scale[1] *= zoomModifier
373 2
      if (setScale) {
374
        setScale(scale[0], scale[1])
375
      }
376
      const zoomToPosition = [
377
        zoomTo[0] / scale[0] - translate[0],
378
        zoomTo[1] / scale[1] - translate[1]
379
      ]
380
      translate[0] -= zoomLastPosition[0] - zoomToPosition[0]
381
      translate[1] += zoomToPosition[1] - zoomLastPosition[1]
382 2
      if (setTranslate) {
383
        setTranslate(translate[0], translate[1])
384
      }
385
      render()
386
    }
387
    canvasEl.addEventListener('wheel', handleWheel)
388
389
    const handleKeyDown = (event: KeyboardEvent): void => {
390 37
      switch (event.code) {
391
  
392
      case 'KeyW':
393
      case 'ArrowUp':
394
      case 'Numpad8':
395
        translate[1] -= translateFactor * zoomFactor
396
        break
397
            
398
      case 'KeyS':
399
      case 'ArrowDown':
400
      case 'Numpad2':
401
        translate[1] += translateFactor * zoomFactor
402
        break
403
             
404
      case 'KeyA':
405
      case 'ArrowLeft':
406
      case 'Numpad4':
407
        translate[0] += translateFactor * zoomFactor
408
        break
409
            
410
      case 'KeyD':
411
      case 'ArrowRight':
412
      case 'Numpad6':
413
        translate[0] -= translateFactor * zoomFactor
414
        break
415
416
      case 'KeyE':
417
      case 'Numpad9':
418
        translate[1] -= translateFactor * zoomFactor
419
        translate[0] -= translateFactor * zoomFactor
420
        break
421
            
422
      case 'KeyC':
423
      case 'Numpad3':
424
        translate[1] += translateFactor * zoomFactor
425
        translate[0] -= translateFactor * zoomFactor
426
        break
427
              
428
      case 'KeyZ':
429
      case 'Numpad1':
430
        translate[0] += translateFactor * zoomFactor
431
        translate[1] += translateFactor * zoomFactor
432
        break
433
            
434
      case 'KeyQ':
435
      case 'Numpad7':
436
        translate[0] += translateFactor * zoomFactor
437
        translate[1] -= translateFactor * zoomFactor
438
        break
439
440
      case 'Space':
441
      case 'KeyO':
442
      case 'Numpad5':
443
        translate[0] = -canvasCenter[0]
444
        translate[1] = -canvasCenter[1]
445
        break
446
    
447
      case 'KeyX':
448
      case 'Numpad0':
449
        translate[0] = translate[1] = 0
450
        break
451
452
      case 'NumpadSubtract':
453
      case 'Minus':
454
        scale[0] *= zoomFactor
455
        scale[1] *= zoomFactor
456 2
        if (setScale) {
457
          setScale(scale[0], scale[1])
458
          render()
459
        } 
460
        break
461
 
462
      case 'NumpadAdd':
463
      case 'Equal':
464
        scale[0] /= zoomFactor
465
        scale[1] /= zoomFactor
466 2
        if (setScale) {
467
          setScale(scale[0], scale[1])
468
          render()
469
        } 
470
        break
471
472
      case 'NumpadDivide':
473
      case 'BracketRight':
474
        scale[0] /= logBase
475
        scale[1] /= logBase
476 2
        if (setScale) {
477
          setScale(scale[0], scale[1])
478
          render()
479
        }
480
        break
481
    
482
      case 'NumpadMultiply':
483
      case 'BracketLeft':
484
        scale[0] *= logBase
485
        scale[1] *= logBase
486 2
        if (setScale) {
487
          setScale(scale[0], scale[1])
488
          render()
489
        } 
490
        break
491
  
492
      case 'Period':
493
      case 'NumpadDecimal':
494
        {
495 2
          const initialScale = rest.initialScale ?? 8/canvasCenter[0]
496
          scale[0] = scale[1] = initialScale
497 2
          if (setScale) {
498
            setScale(scale[0], scale[1])
499
          }
500
        }
501
        break
502
        
503
504
      case 'Escape':
505
      case 'Backspace':
506
        window.location.reload()
507
        break
508
                
509
      }
510
    }
511
    window.addEventListener('keydown', handleKeyDown)
512
513
    return (): void => {
514
      window.cancelAnimationFrame(renderCallbackID)
515
      canvasEl.removeEventListener('mousedown', handleMouseDown)
516
      canvasEl.removeEventListener('mouseup', handleMouseUp)
517
      canvasEl.removeEventListener('contextmenu', handleContextMenu)
518
      canvasEl.removeEventListener('mousemove', handleMouseMove)
519
      canvasEl.removeEventListener('touchstart', handleTouchDown)
520
      canvasEl.removeEventListener('touchend', handleTouchUp)
521
      canvasEl.removeEventListener('touchmove', handleTouchMove)
522
      window.removeEventListener('keydown', handleKeyDown)
523
    }
524
525
  }, [ref])
526
527
  return (
528
    <Canvas 
529
      ref={ref}
530
      className="jrid"
531
      init={init}
532
      onResize={onResize}
533
      draw={draw}
534
      animate={true}
535
      {...rest}
536
    />
537
  )
538
}
539
540
interface JridState {
541
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
542
  [index: string]: any;
543
  locale: string;
544
  setTranslate?: TranslateFunction;
545
  setScale?: ScaleFunction;
546
}
547
548 3
const JridDefaultState: JridState = {
549
  locale: 'en-CA',
550
  setTranslate: undefined,
551
  setScale: undefined
552
}
553
554
export interface JridSettings {
555
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
556
  [index: string]: any;
557
  locale?: string;
558
  setTranslate?: TranslateFunction;
559
  setScale?: ScaleFunction;
560
}
561
562
/**
563
 * @class Jrid
564
 * @name Jrid
565
 */
566
class Jrid {
567
  el: HTMLElement
568
  state: JridSettings = JridDefaultState
569
570
  constructor(el: HTMLElement, settings: JridSettings = {}) {
571
    // super()
572
    this.el = el
573
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
574
    for (const k in settings) this.state[k] = settings[k]
575
    render(
576
      <JridOverlay setTranslate={this.state.setTranslate} setScale={this.state.setScale} />,
577
      this.el
578
    )
579
  }
580
581
  static get version(): string {
582 1
    return VERSION
583
  }
584
585
}
586
587
export default Jrid
588