From a920a2ddd846d9465a1b7acec2a9655823afe4ab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=9C=D0=B0?=
 =?UTF-8?q?=D0=BB=D1=8E=D0=B3=D0=B8=D0=BD?= <d.malygin@iqdev.digital>
Date: Fri, 17 Jan 2025 15:59:17 +0500
Subject: [PATCH] feat: component 'Knob'

---
 src/Playground.vue                          |   3 +
 src/common/helpers/common.ts                |   3 +-
 src/common/interfaces/common.ts             |  20 +++
 src/common/interfaces/componentsProp.ts     |   7 +
 src/common/interfaces/componentsProps.ts    |  17 ++-
 src/stories/components/Knob/Knob.stories.ts | 129 ++++++++++------
 src/stories/components/Knob/Knob.vue        | 157 +++++++++++++++++---
 src/stories/components/Knob/helpers.ts      |  61 ++++++++
 8 files changed, 330 insertions(+), 67 deletions(-)
 create mode 100644 src/stories/components/Knob/helpers.ts

diff --git a/src/Playground.vue b/src/Playground.vue
index ba41d2a..bc22e0d 100644
--- a/src/Playground.vue
+++ b/src/Playground.vue
@@ -17,6 +17,7 @@ import Checkbox from '@stories/components/Checkbox/Checkbox.vue';
 import Tag from '@stories/components/Tag/Tag.vue';
 import Select from '@stories/components/Select/Select.vue';
 import AtIcon from '@stories/icons/Mono/AtIcon.vue';
+import Knob from '@stories/components/Knob/Knob.vue';
 
 const visibleDrawer = ref(false);
 const sliderOptions: ISliderOptions[] = [
@@ -191,10 +192,12 @@ const selectOptions = [
     value: 'Second',
   },
 ];
+const knob = ref();
 </script>
 
 <template>
   <h2 class="title gradient-text">Playground</h2>
+  <Knob v-model:value="knob" />
   <Select :options="selectOptions" theme="sky">
     <template #icon-left-First>
       <AtIcon color="#3aa" size="20" />
diff --git a/src/common/helpers/common.ts b/src/common/helpers/common.ts
index f3e9829..d224eb3 100644
--- a/src/common/helpers/common.ts
+++ b/src/common/helpers/common.ts
@@ -1,4 +1,4 @@
-import type { TDarkness, TThemeColor } from '@interfaces/common';
+import { EThemeColor, type TDarkness, type TThemeColor } from '@interfaces/common';
 import {
   convert100ThemeToColor,
   convert200ThemeToColor,
@@ -15,6 +15,7 @@ import {
  * Convert color of type TThemeColor to hex
  */
 export const convertThemeToColor = (theme: TThemeColor, darkness: TDarkness | string = '500'): string => {
+  if (!(theme in EThemeColor)) return theme;
   if (darkness === '500') return convert500ThemeToColor(theme);
   if (darkness === '100') return convert100ThemeToColor(theme);
   if (darkness === '200') return convert200ThemeToColor(theme);
diff --git a/src/common/interfaces/common.ts b/src/common/interfaces/common.ts
index 7a0383a..cb8b472 100644
--- a/src/common/interfaces/common.ts
+++ b/src/common/interfaces/common.ts
@@ -21,6 +21,26 @@ export type TThemeColor =
   | 'red'
   | 'black';
 
+export enum EThemeColor {
+  'white',
+  'blue',
+  'sky',
+  'cyan',
+  'teal',
+  'lime',
+  'green',
+  'yellow',
+  'amber',
+  'orange',
+  'pink',
+  'fuchsia',
+  'purple',
+  'indigo',
+  'rose',
+  'red',
+  'black',
+}
+
 export type TDarkness = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
 
 export type TThemeColorNoWhite = Exclude<TThemeColor, 'white'>;
diff --git a/src/common/interfaces/componentsProp.ts b/src/common/interfaces/componentsProp.ts
index 8f00281..3b7b90a 100644
--- a/src/common/interfaces/componentsProp.ts
+++ b/src/common/interfaces/componentsProp.ts
@@ -55,6 +55,13 @@ export interface IMDItemProps {
   onClick?: () => void;
 }
 
+export interface IKnobColorGap {
+  start: number;
+  end: number;
+  color: TThemeColor;
+  darknessColor?: TDarkness;
+}
+
 export interface ISelectOption {
   value: string;
   label?: string;
diff --git a/src/common/interfaces/componentsProps.ts b/src/common/interfaces/componentsProps.ts
index ef09ded..46309d8 100644
--- a/src/common/interfaces/componentsProps.ts
+++ b/src/common/interfaces/componentsProps.ts
@@ -11,6 +11,7 @@ import type {
   TThemeColorNoWhite,
 } from '@interfaces/common';
 import type {
+  IKnobColorGap,
   IMDItemProps,
   ISBOption,
   ISelectGroup,
@@ -69,16 +70,25 @@ export interface IMDProps {
 }
 
 export interface IKnobProps {
-  min?: string | number;
-  max?: string | number;
-  step?: string | number;
+  min?: number;
+  max?: number;
+  step?: number;
   size?: TSize;
   theme?: TThemeColor;
+  colorGaps?: IKnobColorGap[];
   negativeTheme?: TThemeColor;
+  color?: TThemeColor;
+  background?: string;
   darknessTheme?: TDarkness;
   darknessNegativeTheme?: TDarkness;
+  darknessColor?: TDarkness;
   buttons?: boolean;
   showLabel?: boolean;
+  colorAsTheme?: boolean;
+  fontSize?: string;
+  textBold?: boolean;
+  textBefore?: string;
+  textAfter?: string;
 }
 
 export interface ISliderProps {
@@ -175,6 +185,7 @@ export interface IButtonProps {
   textStyle?: TTextStyle;
   iconPos?: TPosition;
   width?: string | number;
+  padding?: string;
   iconOnly?: boolean;
   theme?: TThemeColor;
   textColor?: TThemeColor;
diff --git a/src/stories/components/Knob/Knob.stories.ts b/src/stories/components/Knob/Knob.stories.ts
index ea90618..eafa6c6 100644
--- a/src/stories/components/Knob/Knob.stories.ts
+++ b/src/stories/components/Knob/Knob.stories.ts
@@ -16,17 +16,26 @@ const meta: Meta = {
   argTypes: {
     buttons: { control: 'boolean' },
     showLabel: { control: 'boolean' },
-    min: { control: 'text' },
-    max: { control: 'text' },
-    step: { control: 'text' },
-    size: { control: 'Knob', options: ['small', 'normal', 'large', 'huge'] },
-    darknessTheme: { control: 'Knob', options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'] },
+    colorAsTheme: { control: 'boolean' },
+    textBold: { control: 'boolean' },
+    value: { control: 'number' },
+    min: { control: 'number' },
+    max: { control: 'number' },
+    step: { control: 'number' },
+    fontSize: { control: 'text' },
+    textBefore: { control: 'text' },
+    textAfter: { control: 'text' },
+    colorGaps: { control: 'object' },
+    size: { control: 'select', options: ['small', 'normal', 'large', 'huge'] },
+    background: { control: 'color' },
+    darknessTheme: { control: 'select', options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'] },
     darknessNegativeTheme: {
-      control: 'Knob',
+      control: 'select',
       options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
     },
+    darknessColor: { control: 'select', options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'] },
     theme: {
-      control: 'Knob',
+      control: 'select',
       options: [
         'white',
         'blue',
@@ -46,7 +55,27 @@ const meta: Meta = {
       ],
     },
     negativeTheme: {
-      control: 'Knob',
+      control: 'select',
+      options: [
+        'white',
+        'blue',
+        'sky',
+        'cyan',
+        'teal',
+        'green',
+        'yellow',
+        'orange',
+        'pink',
+        'fuchsia',
+        'purple',
+        'indigo',
+        'rose',
+        'red',
+        'black',
+      ],
+    },
+    color: {
+      control: 'select',
       options: [
         'white',
         'blue',
@@ -73,58 +102,66 @@ export default meta;
 type Story = StoryObj<typeof meta>;
 
 export const Simple: Story = {
+  args: {},
+};
+
+export const Half: Story = {
   args: {
-    options: [
-      {
-        value: 'First',
-      },
-      {
-        value: 'Second',
-      },
-      {
-        value: 'Third',
-      },
-    ],
+    textBold: true,
+    colorAsTheme: true,
+    showLabel: true,
+    min: 0,
+    max: 5000,
+    theme: 'red',
+    negativeTheme: 'yellow',
+    value: 2500,
+    textAfter: '',
+    textBefore: '$',
+    step: 1,
+    fontSize: '1.3rem',
   },
 };
 
 export const Full: Story = {
   args: {
-    options: [
+    value: 60,
+    min: 0,
+    max: 100,
+    size: 'huge',
+
+    colorGaps: [
       {
-        value: 'First',
-        iconLeft: 'At',
-        color: 'purple',
-        darknessColor: '800',
-        group: 'Group',
+        start: 0,
+        end: 20,
+        color: 'red',
       },
       {
-        value: 'Second',
-        iconRightColor: 'red',
-        iconRight: 'Age18',
-        group: 'Group',
+        start: 20,
+        end: 40,
+        color: 'orange',
+        darknessColor: '400',
       },
       {
-        iconLeft: 'Calendar',
-        value: 'Third',
-        iconRight: 'CheckMark',
-        group: 'Group 2',
+        start: 40,
+        end: 60,
+        color: 'yellow',
+        darknessColor: '400',
       },
       {
-        value: 'Sssss',
+        start: 60,
+        end: 80,
+        color: 'green',
+        darknessColor: '600',
       },
     ],
-    groups: [
-      { name: 'Group', background: 'white', iconLeft: 'Archive' },
-      { name: 'Group 2', background: 'red', iconLeft: 'Badge' },
-    ],
-    placeholder: 'Knob a city',
-    size: 'normal',
-    width: '250px',
-    theme: 'sky',
-    background: 'sky',
-    darknessTheme: '700',
-    darknessBackground: '200',
-    openIconColor: 'sky',
+
+    theme: 'blue',
+    darknessTheme: '500',
+    step: 2,
+    textBold: true,
+    colorAsTheme: true,
+    textBefore: '',
+    textAfter: '%',
+    buttons: true,
   },
 };
diff --git a/src/stories/components/Knob/Knob.vue b/src/stories/components/Knob/Knob.vue
index 7ed7a92..3e0a74b 100644
--- a/src/stories/components/Knob/Knob.vue
+++ b/src/stories/components/Knob/Knob.vue
@@ -1,20 +1,68 @@
 <script setup lang="ts">
 import type { IKnobProps } from '@interfaces/componentsProps';
-import { computed } from 'vue';
+import { computed, ref, type Ref } from 'vue';
 import { convertThemeToColor } from '@helpers/common';
+import {
+  calcCenter,
+  calcStart,
+  calcNewValue,
+  calcThemeColor,
+  calcContainerSize,
+} from '@stories/components/Knob/helpers';
+import Button from '@stories/components/Button/Button.vue';
 
 const props = withDefaults(defineProps<IKnobProps>(), {
+  value: 0,
   min: 0,
-  max: 2,
+  max: 5,
+  step: 1,
+  size: 'normal',
+  buttons: false,
+  theme: 'sky',
+  darknessTheme: '500',
   negativeTheme: 'black',
+  darknessNegativeTheme: '500',
+  color: 'black',
+  darknessColor: '500',
+  background: 'white',
   showLabel: true,
+  colorAsTheme: false,
+  textBold: false,
 });
-const value = defineModel('value');
+const value = defineModel<number>('value', {
+  default: 0,
+}) as Ref<number>;
+
+const isClickHold = ref<boolean>(false);
 
 const degreesTotal = computed(() => 360 - 90);
 const length = computed(() => props.max - props.min);
-const textColor = computed(() => convertThemeToColor(props.theme, props.darknessTheme));
-const background = computed(() => {
+const center = computed(() => calcCenter(document.querySelector('.container')!));
+const start = computed(() => calcStart(document.querySelector('.container')!));
+const containerSize = computed(() => calcContainerSize(props.size));
+const buttonSize = computed(() => {
+  const size = props.size;
+  return size === 'normal' || size === 'small' ? 'small' : size === 'large' ? 'large' : 'huge';
+});
+const textSize = computed(() => {
+  if (props.fontSize) return props.fontSize;
+  const size = props.size;
+  return size === 'normal' ? '1.7rem' : size === 'small' ? '1.3rem' : size === 'large' ? '2.5rem' : '3.5rem';
+});
+const buttonPadding = computed(() => {
+  const size = props.size;
+  return size === 'normal'
+    ? '0.3rem 0.5rem'
+    : size === 'small'
+      ? '0.2rem'
+      : size === 'large'
+        ? '0.5rem 0.75rem'
+        : '0.7rem 1rem';
+});
+const backgroundSize = computed(() => `${containerSize.value.slice(0, -2) * 0.71}px`);
+const themeColor = computed(() => calcThemeColor(props.colorGaps, props.theme, props.darknessTheme, value.value));
+const textColor = computed(() => convertThemeToColor(props.color, props.darknessColor));
+const backgroundCircle = computed(() => {
   const color = convertThemeToColor(
     props.negativeTheme ?? (props.theme === 'white' ? 'black' : props.theme === 'black' ? 'white' : props.theme),
     (!props.negativeTheme && props.theme === 'black') || props.negativeTheme === 'white'
@@ -24,19 +72,69 @@ const background = computed(() => {
   return `radial-gradient(circle at center, transparent 50%, ${color} 50%)`;
 });
 const conicGradient = computed(() => {
-  return `conic-gradient(red ${(225 + (1 / length.value) * degreesTotal.value) % 360}deg, transparent 225deg)`;
+  const valueDeg = 225 + (value.value / length.value) * degreesTotal.value;
+  if (valueDeg >= 360)
+    return `conic-gradient(${themeColor.value} 0deg ${valueDeg % 360}deg, transparent ${valueDeg % 360}deg 225deg, ${themeColor.value} 225deg 360deg)`;
+  return `conic-gradient(transparent 0deg 225deg, ${themeColor.value} 225deg ${valueDeg}deg, transparent ${valueDeg}deg 360deg)`;
 });
+
+const setNewValue = ($event) => {
+  value.value = calcNewValue(
+    $event,
+    center.value,
+    start.value,
+    degreesTotal.value,
+    length.value,
+    props.step,
+    value.value,
+  );
+};
+const onPointerDown = ($event) => {
+  isClickHold.value = true;
+  setNewValue($event);
+};
 </script>
 
 <template>
-  {{ (225 + (1 / length) * degreesTotal) % 360 }}
-  {{ length }}
-  {{ conicGradient }}
-  <section class="container">
-    <div class="circle" :style="`width: 100px; height: 100px`">
-      <div class="circle selected" :style="`width: 100px; height: 100px`"></div>
-
-      <span v-if="showLabel" class="count">{{ value }}</span>
+  <section
+    @pointerdown.prevent="!buttons && onPointerDown($event)"
+    @pointermove="isClickHold ? setNewValue($event) : ''"
+    @pointerup="isClickHold = false"
+    class="container containerSize"
+  >
+    <div class="background"></div>
+    <span
+      v-if="showLabel"
+      class="count"
+      :style="`color: ${colorAsTheme ? themeColor : textColor};
+       font-weight: ${textBold ? 'bold' : 'medium'};
+       font-size: ${textSize}`"
+      >{{ textBefore ?? '' }}{{ value }}{{ textAfter ?? '' }}</span
+    >
+    <div class="circle containerSize">
+      <div class="circle containerSize selected"></div>
+    </div>
+    <div v-if="buttons" class="buttons" :style="`gap: ${textSize.slice(0, -3) * 3}px`">
+      <Button
+        @click="value++"
+        :theme="negativeTheme"
+        textColor="white"
+        :size="buttonSize"
+        label="+"
+        textStyle="bold"
+        :padding="buttonPadding"
+        :width="`${textSize.slice(0, -3) * 0.78}rem`"
+      ></Button>
+      <Button
+        @click="value--"
+        :theme="negativeTheme"
+        textColor="white"
+        :size="buttonSize"
+        label="-"
+        textStyle="bold"
+        :padding="buttonPadding"
+        :width="`${textSize.slice(0, -3) * 0.78}rem`"
+      ></Button>
     </div>
   </section>
 </template>
@@ -45,25 +143,50 @@ const conicGradient = computed(() => {
 .container {
   position: relative;
 }
+.containerSize {
+  width: v-bind(containerSize);
+  height: v-bind(containerSize);
+}
 .circle {
-  overflow: hidden;
+  position: relative;
   border-radius: 50%;
-  background: v-bind(background);
+  background: v-bind(backgroundCircle);
   clip-path: polygon(0 0, 0 100%, 50% 50%, 50% 50%, 100% 100%, 100% 0);
 }
 .selected {
   position: absolute;
-  z-index: 100;
+  z-index: 3;
   top: 0;
   left: 0;
   width: 100%;
   height: 100%;
   background: v-bind(conicGradient);
 }
+.background {
+  width: v-bind(backgroundSize);
+  height: v-bind(backgroundSize);
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  z-index: 4;
+  background: v-bind(background);
+  border-radius: 50%;
+  transform: translate(-50%, -50%);
+}
 .count {
   position: absolute;
   top: 50%;
   left: 50%;
+  z-index: 5;
   transform: translate(-50%, -50%);
+  user-select: none;
+}
+.buttons {
+  display: flex;
+  position: absolute;
+  bottom: 0;
+  left: 50%;
+  transform: translateX(-50%);
+  z-index: 5;
 }
 </style>
diff --git a/src/stories/components/Knob/helpers.ts b/src/stories/components/Knob/helpers.ts
new file mode 100644
index 0000000..abc880e
--- /dev/null
+++ b/src/stories/components/Knob/helpers.ts
@@ -0,0 +1,61 @@
+import { convertThemeToColor } from '@helpers/common';
+import { EThemeColor, type TDarkness, type TSize, type TThemeColor } from '@interfaces/common';
+import type { IKnobColorGap } from '@interfaces/componentsProp';
+
+export const calcNewValue = (
+  event: MouseEvent,
+  center: number[],
+  start: number[],
+  degreesTotal: number,
+  length: number,
+  step: number,
+  value: number,
+) => {
+  const x = event.clientX;
+  const y = event.clientY;
+  const a = Math.sqrt(Math.pow(x - center[0], 2) + Math.pow(y - center[1], 2));
+  const b = Math.sqrt(Math.pow(x - start[0], 2) + Math.pow(y - start[1], 2));
+  const c = Math.sqrt(Math.pow(center[0] - start[0], 2) + Math.pow(center[1] - start[1], 2));
+
+  let angle = (Math.acos((Math.pow(a, 2) - Math.pow(b, 2) + Math.pow(c, 2)) / (2 * a * c)) * 180) / Math.PI;
+
+  const emptyDegreesHalf = (360 - degreesTotal) / 2;
+  if (angle < emptyDegreesHalf) return value;
+  if (x > center[0]) angle = 360 - angle;
+
+  return Math.round((angle - emptyDegreesHalf) / ((degreesTotal / length) * step)) * step;
+};
+
+export const calcCenter = (container: Element) => {
+  const clientRect = container.getBoundingClientRect();
+  return [
+    clientRect.left + (clientRect.right - clientRect.left) / 2,
+    clientRect.top + (clientRect.bottom - clientRect.top) / 2,
+  ];
+};
+
+export const calcStart = (container: Element) => {
+  const clientRect = container.getBoundingClientRect();
+  return [clientRect.left + (clientRect.right - clientRect.left) / 2, clientRect.bottom];
+};
+
+export const calcThemeColor = (
+  colorGaps: IKnobColorGap[],
+  theme: TThemeColor,
+  darknessTheme: TDarkness,
+  value: number,
+) => {
+  if (!colorGaps) return convertThemeToColor(theme, darknessTheme);
+  const current = colorGaps.find((item) => item.start <= value && value <= item.end);
+  if (!current) return convertThemeToColor(theme, darknessTheme);
+  return current.color in EThemeColor
+    ? convertThemeToColor(current.color, current.darknessColor ?? '500')
+    : current.color;
+};
+
+export const calcContainerSize = (size: TSize) => {
+  if (size === 'normal') return '100px';
+  if (size === 'large') return '150px';
+  if (size === 'huge') return '200px';
+  return '70px';
+};
-- 
GitLab