Skip to content

Commit 9e94f0d

Browse files
committed
feat: canvas powered KnobAssetStack
1 parent f225e01 commit 9e94f0d

File tree

6 files changed

+227
-91
lines changed

6 files changed

+227
-91
lines changed

docs/components/knob.md

+28-17
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ It will automatically lock the pointer to the knob, making sure all mouse moveme
3030
<script setup>
3131
import { ref } from 'vue'
3232
import { Knob, KnobAssetStack } from '../../src'
33-
import Background from '../assets/example-knob/background.svg'
34-
import Handle from '../assets/example-knob/handle.svg'
35-
import Track from '../assets/example-knob/track.svg'
33+
import BackgroundComponent from '../assets/example-knob/background.svg'
34+
import HandleComponent from '../assets/example-knob/handle.svg'
35+
import TrackComponent from '../assets/example-knob/track.svg'
36+
import Background from '../assets/example-knob/background.svg?raw'
37+
import Handle from '../assets/example-knob/handle.svg?raw'
38+
import Track from '../assets/example-knob/track.svg?raw'
3639

3740
const volume = ref(100)
3841
</script>
@@ -167,35 +170,43 @@ Using this pattern, you can easily supply your own graphics, making knobs fit wh
167170
1. An optional "track" for the knob
168171
1. A "handle" for the knob
169172

170-
The component assumes all layers are the same size, and simplifies common implementations by automatically stacking them on top of each other. While the background layer remains static at all times, the track will fill up behind the handle, and the handle will rotate with the value.
173+
The component assumes all layers are the same size, and simplifies common implementations by automatically stacking them on top of each other. While the background layer remains static at all times, the track will either allow clipping, or fill up behind the handle, and the handle will rotate with the value. To ensure performance, this is being rendered in a canvas element to keep all transformations in-sync.
171174

172175
<Knob
173176
v-slot="{ percentage }"
174177
v-model="volume"
175178
>
176179
<KnobAssetStack
177-
:style="{
178-
width: '128px',
179-
height: '128px',
180-
}"
180+
:width="128"
181+
:height="128"
181182
:min-degrees="-140"
182183
:max-degrees="140"
183184
:percentage="percentage"
184-
:background="Background"
185-
:track="Track"
186-
:handle="Handle"
185+
:background-svg="Background"
186+
:track-svg="Track"
187+
:handle-svg="Handle"
187188
/>
188189
</Knob>
189190

190191
Volume: {{ volume }}
191192

193+
This example is composed of the following three layers (background, track, handle) using the default clipping mode for the track to expose the background:
194+
195+
<div :style="{ display: 'flex' }">
196+
<BackgroundComponent :style="{ height: '64px', marginRight: '8px' }" />
197+
<TrackComponent :style="{ height: '64px', marginRight: '8px' }" />
198+
<HandleComponent :style="{ height: '64px' }" />
199+
</div>
200+
201+
If you have a track asset that should instead be shown as the handle moves, you can change the `track-mode` of the asset stack component to `Fill`.
202+
192203
```vue
193204
<script setup>
194205
import { ref } from 'vue'
195206
import { Knob, KnobAssetStack } from 'acousti-kit'
196-
import Background from '../assets/example-knob/background.svg'
197-
import Handle from '../assets/example-knob/handle.svg'
198-
import Track from '../assets/example-knob/track.svg'
207+
import Background from '../assets/example-knob/background.svg?raw'
208+
import Handle from '../assets/example-knob/handle.svg?raw'
209+
import Track from '../assets/example-knob/track.svg?raw'
199210
200211
const volume = ref(100)
201212
</script>
@@ -213,9 +224,9 @@ const volume = ref(100)
213224
:min-degrees="-140"
214225
:max-degrees="140"
215226
:percentage="percentage"
216-
:background="Background"
217-
:track="Track"
218-
:handle="Handle"
227+
:background-svg="Background"
228+
:track-svg="Track"
229+
:handle-svg="Handle"
219230
/>
220231
</Knob>
221232
</template>

src/components/KnobAssetStack.vue

-73
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script setup lang="ts">
2+
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
3+
import { KnobAssetStackCanvasRenderer } from './renderer'
4+
import { TrackBehaviour } from './types'
5+
6+
const props = withDefaults(defineProps<{
7+
width: number
8+
height: number
9+
minDegrees?: number
10+
maxDegrees?: number
11+
percentage: number
12+
backgroundSvg?: string
13+
trackSvg?: string
14+
handleSvg: string
15+
trackMode?: TrackBehaviour
16+
}>(), {
17+
minDegrees: -140,
18+
maxDegrees: 140,
19+
trackMode: TrackBehaviour.Clip,
20+
})
21+
22+
const canvas = ref<null | HTMLCanvasElement>(null)
23+
const renderer = ref<null | KnobAssetStackCanvasRenderer>(null)
24+
25+
watch(() => props.percentage, (updatedPercentage) => {
26+
if (renderer.value)
27+
renderer.value.update(updatedPercentage)
28+
}, { immediate: true })
29+
30+
onMounted(async () => {
31+
if (!canvas.value)
32+
throw new Error('Canvas element could not mount')
33+
34+
renderer.value = await KnobAssetStackCanvasRenderer.create(
35+
canvas.value,
36+
{
37+
minDegrees: props.minDegrees,
38+
maxDegrees: props.maxDegrees,
39+
trackMode: props.trackMode,
40+
backgroundSvg: props.backgroundSvg,
41+
trackSvg: props.trackSvg,
42+
handleSvg: props.handleSvg,
43+
},
44+
)
45+
renderer.value.update(props.percentage)
46+
})
47+
48+
onBeforeUnmount(() => {
49+
if (renderer.value)
50+
renderer.value.destroy()
51+
})
52+
</script>
53+
54+
<template>
55+
<canvas
56+
ref="canvas"
57+
:width="width"
58+
:height="height"
59+
/>
60+
</template>
+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { TrackBehaviour } from './types'
2+
3+
async function loadSvgImage(svg?: string): Promise<HTMLImageElement | null> {
4+
if (!svg)
5+
return Promise.resolve(null)
6+
7+
return new Promise((resolve, reject) => {
8+
const image = new Image()
9+
image.src = `data:image/svg+xml;base64,${window.btoa(svg)}`
10+
image.onload = () => {
11+
resolve(image)
12+
}
13+
image.onerror = (event) => {
14+
reject(event.toString())
15+
}
16+
})
17+
}
18+
19+
export interface KnobAssetStackCanvasRendererOptions {
20+
backgroundSvg?: string
21+
trackSvg?: string
22+
handleSvg?: string
23+
minDegrees: number
24+
maxDegrees: number
25+
trackMode: TrackBehaviour
26+
}
27+
28+
export class KnobAssetStackCanvasRenderer {
29+
private context: CanvasRenderingContext2D
30+
31+
private readonly minRad: number
32+
private readonly maxRad: number
33+
34+
private constructor(
35+
private readonly canvas: HTMLCanvasElement,
36+
minDegrees: number,
37+
maxDegrees: number,
38+
private readonly trackMode: TrackBehaviour,
39+
private readonly backgroundImage: CanvasImageSource | null,
40+
private readonly trackImage: CanvasImageSource | null,
41+
private readonly handleImage: CanvasImageSource | null,
42+
) {
43+
const ctx = canvas.getContext('2d')
44+
if (!ctx)
45+
throw new Error('Canvas context not supported')
46+
47+
this.context = ctx
48+
49+
// We always start in bottom left corner (canvas starts on the positive x axis)
50+
this.minRad = (minDegrees - 90) * Math.PI / 180
51+
52+
// And end in lower right corner (canvas starts on the positive x axis)
53+
this.maxRad = (maxDegrees - 90) * Math.PI / 180
54+
}
55+
56+
static async create(
57+
canvas: HTMLCanvasElement,
58+
options: KnobAssetStackCanvasRendererOptions,
59+
) {
60+
const [background, track, handle] = await Promise.all([
61+
loadSvgImage(options.backgroundSvg),
62+
loadSvgImage(options.trackSvg),
63+
loadSvgImage(options.handleSvg),
64+
])
65+
return new KnobAssetStackCanvasRenderer(
66+
canvas,
67+
options.minDegrees,
68+
options.maxDegrees,
69+
options.trackMode,
70+
background,
71+
track,
72+
handle,
73+
)
74+
}
75+
76+
destroy() {
77+
}
78+
79+
update(percentage: number) {
80+
this.draw(percentage)
81+
}
82+
83+
draw(percentage: number) {
84+
// The total range of rads between start and stop
85+
const range = this.maxRad - this.minRad
86+
87+
// The current number of rads from start
88+
const delta = range * (50 - percentage) / 100
89+
90+
this.context.setTransform(1, 0, 0, 1, 0, 0)
91+
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
92+
this.context.translate(this.canvas.width / 2, this.canvas.height / 2)
93+
94+
if (this.backgroundImage)
95+
this.context.drawImage(this.backgroundImage, -this.canvas.width / 2, -this.canvas.height / 2, this.canvas.width, this.canvas.height)
96+
97+
if (this.trackImage) {
98+
this.context.save()
99+
this.context.beginPath()
100+
101+
if (this.trackMode === TrackBehaviour.Clip) {
102+
this.context.arc(
103+
0,
104+
0,
105+
this.canvas.height * 2,
106+
-delta - Math.PI / 2,
107+
this.maxRad,
108+
false,
109+
)
110+
}
111+
else {
112+
this.context.arc(
113+
0,
114+
0,
115+
this.canvas.height * 2,
116+
this.minRad,
117+
-delta - Math.PI / 2,
118+
false,
119+
)
120+
}
121+
122+
this.context.lineTo(0, 0)
123+
this.context.clip()
124+
this.context.drawImage(this.trackImage, -this.canvas.width / 2, -this.canvas.height / 2, this.canvas.width, this.canvas.height)
125+
this.context.restore()
126+
}
127+
128+
if (this.handleImage) {
129+
this.context.rotate(-delta)
130+
this.context.drawImage(this.handleImage, -this.canvas.width / 2, -this.canvas.height / 2, this.canvas.width, this.canvas.height)
131+
this.context.rotate(delta)
132+
}
133+
}
134+
}
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum TrackBehaviour {
2+
Clip,
3+
Fill,
4+
}

src/components/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Knob from './Knob.vue'
2-
import KnobAssetStack from './KnobAssetStack.vue'
2+
import KnobAssetStack from './KnobAssetStack/KnobAssetStack.vue'
33
import MouseControl from './MouseControl.vue'
44

55
export { Knob, KnobAssetStack, MouseControl }

0 commit comments

Comments
 (0)