Skip to content

Commit 24c5300

Browse files
committed
Add knob v1
1 parent f5d4c52 commit 24c5300

File tree

8 files changed

+493
-0
lines changed

8 files changed

+493
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@layer components {
2+
.bcc-knob {
3+
4+
}
5+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { mount } from "@vue/test-utils";
4+
import BccKnob from "./BccKnob.vue";
5+
6+
describe("BccKnob", () => {
7+
it("renders a knob", () => {
8+
expect(BccKnob).toBeTruthy();
9+
10+
const wrapper = mount(BccKnob, {
11+
props: { modelValue: 30 },
12+
});
13+
14+
expect(wrapper.html()).toMatchSnapshot();
15+
});
16+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { ref } from "vue";
2+
import BccKnob from "./BccKnob.vue";
3+
import BccKnobGradientBackground from "./BccKnobGradientBackground.vue";
4+
5+
import type { Meta, StoryFn } from "@storybook/vue3";
6+
7+
export default {
8+
title: "Other/BccKnob",
9+
component: BccKnob,
10+
argTypes: {
11+
min: {
12+
control: { type: "number" },
13+
description: "Minimum value of the slider",
14+
defaultValue: -120,
15+
},
16+
max: {
17+
control: { type: "number" },
18+
description: "Maximum value of the slider",
19+
defaultValue: 120,
20+
},
21+
modelValue: {
22+
control: { type: "number" },
23+
description: "Current value of the slider",
24+
defaultValue: 50,
25+
},
26+
},
27+
} as Meta<typeof BccKnob>;
28+
29+
const Template: StoryFn<typeof BccKnob> = (args) => ({
30+
components: { BccKnob, BccKnobGradientBackground },
31+
setup() {
32+
const value = ref(0);
33+
return { args, value };
34+
},
35+
template: `
36+
<div class="relative flex flex-col justify-center items-center">
37+
<BccKnob v-bind="args" v-model="value" />
38+
<div class="absolute inset-0 flex justify-center items-center pointer-events-none">
39+
<pre>{{ value }}</pre>
40+
</div>
41+
</div>
42+
`,
43+
});
44+
45+
export const Example = Template.bind({});
46+
Example.args = {};
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
<template>
2+
<div
3+
class="knob"
4+
ref="knobContainer"
5+
@mousedown="startDrag"
6+
@touchstart="startDrag"
7+
@mousemove="onDrag"
8+
@touchmove="onDrag"
9+
@mouseup="endDrag"
10+
@touchend="endDrag"
11+
@mouseleave="endDrag"
12+
>
13+
<canvas ref="canvasEl" :width="size" :height="size"></canvas>
14+
</div>
15+
</template>
16+
17+
<script setup lang="ts">
18+
import { ref, computed, onMounted } from "vue";
19+
20+
const props = defineProps({
21+
solidColor: { type: String, default: "#A9BABA" },
22+
thumbColor: { type: String, default: "#437571" },
23+
min: { type: Number, default: -720 }, // in minutes (-12h)
24+
max: { type: Number, default: 720 }, // in minutes (+12h)
25+
});
26+
27+
// Our model value (in minutes)
28+
const value = defineModel<number>({ required: true });
29+
30+
const size = ref(320);
31+
const arcWidth = ref(20);
32+
33+
// Refs for container and canvas; we'll get the 2D context on mount.
34+
const knobContainer = ref<HTMLDivElement | null>(null);
35+
const canvasEl = ref<HTMLCanvasElement | null>(null);
36+
let ctx: CanvasRenderingContext2D | null = null;
37+
38+
// Drag state and angles (all in degrees)
39+
const isDragging = ref(false);
40+
const totalAngle = ref(0);
41+
const maxAngleReached = ref(0);
42+
const lastAngle = ref(0);
43+
44+
// Compute center and radius for drawing.
45+
const center = computed(() => size.value / 2);
46+
const radius = computed(() => center.value - arcWidth.value);
47+
48+
// Convert our min/max (in minutes) to degrees.
49+
const minDegrees = computed(() => (props.min / 60) * 360);
50+
const maxDegrees = computed(() => (props.max / 60) * 360);
51+
52+
// Clamp the background arc angle to a full circle.
53+
const backgroundArcAngle = computed(() =>
54+
Math.max(
55+
-359.99,
56+
Math.min(359.99, Math.max(minDegrees.value, Math.min(totalAngle.value, maxDegrees.value)))
57+
)
58+
);
59+
60+
// Display text (in hours)
61+
const displayValue = computed(() => (totalAngle.value / 360).toFixed(2) + "h");
62+
63+
/**
64+
* For canvas.arc we need radians with 0 at the top.
65+
* This helper subtracts 90° so that 0° is at the top.
66+
*/
67+
function toRad(deg: number) {
68+
return ((deg - 90) * Math.PI) / 180;
69+
}
70+
71+
/**
72+
* Convert an angle (in our knob degrees) to Cartesian coordinates.
73+
* (0° is at the top; positive angles rotate clockwise.)
74+
*/
75+
function angleToCartesian(angleDeg: number, r: number = radius.value) {
76+
const rad = (angleDeg * Math.PI) / 180;
77+
return {
78+
x: center.value + r * Math.sin(rad),
79+
y: center.value - r * Math.cos(rad),
80+
};
81+
}
82+
83+
/**
84+
* Main drawing routine.
85+
*/
86+
function drawCanvas() {
87+
if (!ctx) return;
88+
ctx.clearRect(0, 0, size.value, size.value);
89+
90+
// 1. Draw background circle with a subtle drop shadow.
91+
ctx.save();
92+
ctx.shadowColor = "rgba(0,0,0,0.2)";
93+
ctx.shadowBlur = 10;
94+
ctx.shadowOffsetX = 0;
95+
ctx.shadowOffsetY = 4;
96+
ctx.beginPath();
97+
ctx.arc(center.value, center.value, radius.value, 0, 2 * Math.PI);
98+
ctx.strokeStyle = "#efefef";
99+
ctx.lineWidth = arcWidth.value;
100+
ctx.stroke();
101+
ctx.restore();
102+
103+
// 2. Draw inner circle.
104+
ctx.beginPath();
105+
ctx.arc(center.value, center.value, radius.value * 0.2, 0, 2 * Math.PI);
106+
ctx.fillStyle = "#fafafa";
107+
ctx.fill();
108+
109+
// 3. Draw 12 tick marks.
110+
ctx.strokeStyle = "#D4D4D4";
111+
ctx.lineWidth = 4;
112+
for (let n = 1; n <= 12; n++) {
113+
const angle = n * 30;
114+
ctx.save();
115+
ctx.translate(center.value, center.value);
116+
ctx.rotate((angle * Math.PI) / 180);
117+
ctx.beginPath();
118+
ctx.moveTo(0, -radius.value + arcWidth.value * 0.8);
119+
ctx.lineTo(0, -radius.value + arcWidth.value * 2);
120+
ctx.stroke();
121+
ctx.restore();
122+
}
123+
124+
// 4. Draw the solid background arc.
125+
if (Math.abs(backgroundArcAngle.value) >= 108) {
126+
ctx.beginPath();
127+
const anticlockwise = backgroundArcAngle.value < 0;
128+
ctx.arc(
129+
center.value,
130+
center.value,
131+
radius.value,
132+
toRad(0),
133+
toRad(backgroundArcAngle.value),
134+
anticlockwise
135+
);
136+
ctx.strokeStyle = props.solidColor;
137+
ctx.lineWidth = arcWidth.value;
138+
ctx.stroke();
139+
}
140+
141+
// 5. Draw the gradient “handle” arc using a linear gradient.
142+
// The handle spans up to 108° (≈30% of a full rotation).
143+
const bgAngle = totalAngle.value;
144+
let handleStartDeg: number;
145+
if (Math.abs(bgAngle) < 108) {
146+
handleStartDeg = 0;
147+
} else {
148+
handleStartDeg = bgAngle - Math.sign(bgAngle) * 108;
149+
}
150+
const handleEndDeg = bgAngle;
151+
const thickness = arcWidth.value / 2;
152+
ctx.save();
153+
ctx.beginPath();
154+
const anticlockwise = handleEndDeg < handleStartDeg;
155+
// Outer edge of the handle arc.
156+
ctx.arc(
157+
center.value,
158+
center.value,
159+
radius.value + thickness,
160+
toRad(handleStartDeg),
161+
toRad(handleEndDeg),
162+
anticlockwise
163+
);
164+
// Inner edge of the handle arc.
165+
ctx.arc(
166+
center.value,
167+
center.value,
168+
radius.value - thickness,
169+
toRad(handleEndDeg),
170+
toRad(handleStartDeg),
171+
!anticlockwise
172+
);
173+
ctx.closePath();
174+
// Clip to the handle arc.
175+
ctx.save();
176+
ctx.clip();
177+
178+
// Gradient for following handle
179+
// Create the conic gradient with stops matching the original design.
180+
const grad = ctx.createConicGradient(
181+
((totalAngle.value - 90) * Math.PI) / 180,
182+
center.value,
183+
center.value
184+
);
185+
grad.addColorStop(0, props.thumbColor);
186+
grad.addColorStop(100 / 360, props.solidColor);
187+
grad.addColorStop(260 / 360, props.solidColor);
188+
grad.addColorStop(1, props.thumbColor);
189+
ctx.fillStyle = grad;
190+
// Fill the entire canvas (only the clipped area is affected).
191+
ctx.fillRect(0, 0, size.value, size.value);
192+
ctx.restore();
193+
ctx.restore();
194+
195+
// 6. Draw a white circle at the end of the arc (the drag handle).
196+
// Use totalAngle (instead of the clamped backgroundArcAngle) so that the handle rotates continuously.
197+
const handlePos = angleToCartesian(totalAngle.value, radius.value);
198+
const handleCircleRadius = arcWidth.value * 0.8; // a bit larger than the stroke.
199+
ctx.beginPath();
200+
ctx.arc(handlePos.x, handlePos.y, handleCircleRadius, 0, 2 * Math.PI);
201+
ctx.fillStyle = "#fff";
202+
ctx.fill();
203+
ctx.lineWidth = 2;
204+
ctx.strokeStyle = "#ccc";
205+
ctx.stroke();
206+
}
207+
208+
/**
209+
* Returns the angle (in degrees) relative to 12 o’clock.
210+
*/
211+
function getMouseAngle(evt: MouseEvent | TouchEvent): number {
212+
if (!knobContainer.value) return 0;
213+
const rect = knobContainer.value.getBoundingClientRect();
214+
const cx = rect.left + center.value;
215+
const cy = rect.top + center.value;
216+
let x: number, y: number;
217+
if ("touches" in evt && evt.touches.length) {
218+
x = evt.touches[0].clientX - cx;
219+
y = evt.touches[0].clientY - cy;
220+
} else if ("clientX" in evt) {
221+
x = evt.clientX - cx;
222+
y = evt.clientY - cy;
223+
} else {
224+
return 0;
225+
}
226+
// Ignore input if too close to the center.
227+
const distanceFromCenter = Math.sqrt(x * x + y * y);
228+
if (distanceFromCenter < size.value * 0.1) {
229+
return lastAngle.value;
230+
}
231+
const rad = Math.atan2(x, -y);
232+
return (rad * 180) / Math.PI;
233+
}
234+
235+
// ----- Interaction Handlers -----
236+
function startDrag(e: MouseEvent | TouchEvent) {
237+
isDragging.value = true;
238+
lastAngle.value = getMouseAngle(e);
239+
}
240+
241+
let animationFrame = 0;
242+
function onDrag(e: MouseEvent | TouchEvent) {
243+
if (!isDragging.value) return;
244+
if (animationFrame) cancelAnimationFrame(animationFrame);
245+
animationFrame = requestAnimationFrame(() => {
246+
const angle = getMouseAngle(e);
247+
let delta = angle - lastAngle.value;
248+
// Adjust for crossing the ±180° boundary.
249+
if (delta > 180) delta -= 360;
250+
if (delta < -180) delta += 360;
251+
const newTotalAngle = Math.round(
252+
Math.max(minDegrees.value, Math.min(maxDegrees.value, totalAngle.value + delta))
253+
);
254+
if (newTotalAngle !== totalAngle.value) {
255+
totalAngle.value = newTotalAngle;
256+
if (Math.abs(totalAngle.value) > Math.abs(maxAngleReached.value)) {
257+
maxAngleReached.value = totalAngle.value;
258+
}
259+
lastAngle.value = angle;
260+
const inMinutes = Math.round((totalAngle.value / 360) * 60);
261+
if (inMinutes !== value.value) {
262+
value.value = inMinutes;
263+
}
264+
drawCanvas();
265+
}
266+
});
267+
}
268+
269+
function endDrag() {
270+
isDragging.value = false;
271+
}
272+
273+
// Initialize the canvas context on mount.
274+
onMounted(() => {
275+
if (canvasEl.value) {
276+
ctx = canvasEl.value.getContext("2d");
277+
drawCanvas();
278+
}
279+
});
280+
</script>
281+
282+
<style scoped>
283+
.knob {
284+
display: inline-block;
285+
user-select: none;
286+
touch-action: none;
287+
}
288+
</style>

0 commit comments

Comments
 (0)