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