Skip to content

Commit 83ed853

Browse files
authored
feat(slide-potentiometer): add slide potentiometer (#63)
1 parent 3428233 commit 83ed853

File tree

6 files changed

+277
-7
lines changed

6 files changed

+277
-7
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export { SlideSwitchElement } from './slide-switch-element';
2525
export { HCSR04Element } from './hc-sr04-element';
2626
export { LCD2004Element } from './lcd2004-element';
2727
export { AnalogJoystickElement } from './analog-joystick-element';
28+
export { SlidePotentiometerElement } from './slide-potentiometer-element';
2829
export { IRReceiverElement } from './ir-receiver-element';
2930
export { IRRemoteElement } from './ir-remote-element';
3031
export { PIRMotionSensorElement } from './pir-motion-sensor-element';

src/potentiometer-element.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { css, customElement, html, LitElement, property } from 'lit-element';
22
import { styleMap } from 'lit-html/directives/style-map';
33
import { analog, ElementPin } from './pin';
4+
import { clamp } from './utils/clamp';
45

56
interface Point {
67
x: number;
@@ -54,10 +55,6 @@ export class PotentiometerElement extends LitElement {
5455
`;
5556
}
5657

57-
clamp(min: number, max: number, value: number): number {
58-
return Math.min(Math.max(value, min), max);
59-
}
60-
6158
mapToMinMax(value: number, min: number, max: number): number {
6259
return value * (max - min) + min;
6360
}
@@ -67,7 +64,7 @@ export class PotentiometerElement extends LitElement {
6764
}
6865

6966
render() {
70-
const percent = this.clamp(0, 1, this.percentFromMinMax(this.value, this.min, this.max));
67+
const percent = clamp(0, 1, this.percentFromMinMax(this.value, this.min, this.max));
7168
const knobDeg = (this.endDegree - this.startDegree) * percent + this.startDegree;
7269

7370
return html`
@@ -222,15 +219,15 @@ export class PotentiometerElement extends LitElement {
222219
deg -= 360;
223220
}
224221

225-
deg = this.clamp(this.startDegree, this.endDegree, deg);
222+
deg = clamp(this.startDegree, this.endDegree, deg);
226223
const percent = this.percentFromMinMax(deg, this.startDegree, this.endDegree);
227224
const value = this.mapToMinMax(percent, this.min, this.max);
228225

229226
this.updateValue(value);
230227
}
231228

232229
private updateValue(value: number) {
233-
const clamped = this.clamp(this.min, this.max, value);
230+
const clamped = clamp(this.min, this.max, value);
234231
const updated = Math.round(clamped / this.step) * this.step;
235232
this.value = Math.round(updated * 100) / 100;
236233
this.dispatchEvent(new InputEvent('input', { detail: this.value }));

src/react-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { SlideSwitchElement } from './slide-switch-element';
2424
import { HCSR04Element } from './hc-sr04-element';
2525
import { LCD2004Element } from './lcd2004-element';
2626
import { AnalogJoystickElement } from './analog-joystick-element';
27+
import { SlidePotentiometerElement } from './slide-potentiometer-element';
2728
import { IRReceiverElement } from './ir-receiver-element';
2829
import { IRRemoteElement } from './ir-remote-element';
2930
import { PIRMotionSensorElement } from './pir-motion-sensor-element';
@@ -57,6 +58,7 @@ declare global {
5758
'wokwi-hc-sr04': WokwiElement<HCSR04Element>;
5859
'wokwi-lcd2004': WokwiElement<LCD2004Element>;
5960
'wokwi-analog-joystick': WokwiElement<AnalogJoystickElement>;
61+
'wokwi-slide-potentiometer': WokwiElement<SlidePotentiometerElement>;
6062
'wokwi-ir-receiver': WokwiElement<IRReceiverElement>;
6163
'wokwi-ir-remote': WokwiElement<IRRemoteElement>;
6264
'wokwi-pir-motion-sensor': WokwiElement<PIRMotionSensorElement>;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { html } from 'lit-html';
2+
import { action } from '@storybook/addon-actions';
3+
import './slide-potentiometer-element';
4+
5+
export default {
6+
title: 'Slide Potentiometer',
7+
component: 'wokwi-slide-potentiometer',
8+
};
9+
10+
const Template = ({ degrees = 0 }) => html` <div
11+
style="transform: rotate(${degrees}deg) translate(50%, 50%); width:400px; height: 400px;"
12+
>
13+
<wokwi-slide-potentiometer @input=${action('input')} />
14+
</div>`;
15+
16+
export const Default = Template.bind({});
17+
Default.args = {};
18+
19+
export const Rotated = Template.bind({});
20+
Rotated.args = { ...Default.args, degrees: 90 };

src/slide-potentiometer-element.ts

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { css, customElement, html, LitElement, property, svg } from 'lit-element';
2+
import { analog, ElementPin } from './pin';
3+
import { clamp } from './utils/clamp';
4+
5+
@customElement('wokwi-slide-potentiometer')
6+
export class SlidePotentiometerElement extends LitElement {
7+
@property() value = 0;
8+
@property() min = 0;
9+
@property() max = 100;
10+
@property() step = 2;
11+
readonly pinInfo: ElementPin[] = [
12+
{ name: 'VCC', x: 1, y: 43, number: 1, signals: [{ type: 'power', signal: 'VCC' }] },
13+
{ name: 'SIG', x: 1, y: 66.5, number: 2, signals: [analog(0)] },
14+
{ name: 'GND', x: 207, y: 43, number: 3, signals: [{ type: 'power', signal: 'GND' }] },
15+
];
16+
private isPressed = false;
17+
private zoom = 1;
18+
private pageToLocalTransformationMatrix: DOMMatrix | null = null;
19+
20+
static get styles() {
21+
return css`
22+
.hide-input {
23+
position: absolute;
24+
clip: rect(0 0 0 0);
25+
width: 1px;
26+
height: 1px;
27+
margin: -1px;
28+
}
29+
input:focus + svg #tip {
30+
/* some style to add when the element has focus */
31+
filter: url(#outline);
32+
}
33+
`;
34+
}
35+
36+
render() {
37+
const { value, min: minValue, max: maxValue } = this;
38+
const tipTravelInMM = 30;
39+
// Tip is centered by default
40+
const tipBaseOffsetX = -(tipTravelInMM / 2);
41+
const tipMovementX = (value / (maxValue - minValue)) * tipTravelInMM;
42+
const tipOffSetX = tipMovementX + tipBaseOffsetX;
43+
return html`
44+
<input
45+
tabindex="0"
46+
type="range"
47+
min="${this.min}"
48+
max="${this.max}"
49+
value="${this.value}"
50+
step="${this.step}"
51+
aria-valuemin="${this.min}"
52+
aria-valuenow="${this.value}"
53+
aria-valuemax="${this.max}"
54+
@input="${this.onInputValueChange}"
55+
class="hide-input"
56+
/>
57+
<svg
58+
width="55mm"
59+
height="29mm"
60+
version="1.1"
61+
viewBox="0 0 55 29"
62+
xmlns="http://www.w3.org/2000/svg"
63+
xmlns:xlink="http://www.w3.org/1999/xlink"
64+
>
65+
<defs>
66+
<filter id="outline">
67+
<feDropShadow dx="0" dy="0" stdDeviation="1" flood-color="#4faaff" />
68+
</filter>
69+
<linearGradient
70+
id="tipGradient"
71+
x1="36.482"
72+
x2="50.447"
73+
y1="91.25"
74+
y2="91.25"
75+
gradientTransform="matrix(.8593 0 0 1.1151 -14.849 -92.256)"
76+
gradientUnits="userSpaceOnUse"
77+
>
78+
<stop stop-color="#1a1a1a" offset="0" />
79+
<stop stop-color="#595959" offset=".4" />
80+
<stop stop-color="#595959" offset=".6" />
81+
<stop stop-color="#1a1a1a" offset="1" />
82+
</linearGradient>
83+
<radialGradient
84+
id="bodyGradient"
85+
cx="62.59"
86+
cy="65.437"
87+
r="22.5"
88+
gradientTransform="matrix(1.9295 3.7154e-8 0 .49697 -98.268 -23.02)"
89+
gradientUnits="userSpaceOnUse"
90+
>
91+
<stop stop-color="#d2d2d2" offset="0" />
92+
<stop stop-color="#2a2a2a" offset="1" />
93+
</radialGradient>
94+
<g id="screw">
95+
<circle cx="0" cy="0" r="1" fill="#858585" stroke="#000" stroke-width=".05" />
96+
<path d="m0 1 0-2" fill="none" stroke="#000" stroke-width=".151" />
97+
</g>
98+
</defs>
99+
<!-- pins -->
100+
<g fill="#ccc">
101+
<rect x="0" y="11" width="5" height="0.75" />
102+
<rect x="50" y="11" width="5" height="0.75" />
103+
<rect x="0" y="17.25" width="5" height="0.75" />
104+
</g>
105+
<g transform="translate(5 5)">
106+
<!-- Body -->
107+
<rect
108+
id="sliderCase"
109+
x="0"
110+
y="5"
111+
width="45"
112+
height="9"
113+
rx=".2"
114+
ry=".2"
115+
fill="url(#bodyGradient)"
116+
fill-rule="evenodd"
117+
/>
118+
<rect x="3.25" y="8" width="38.5" height="3" rx=".1" ry=".1" fill="#3f1e1e" />
119+
<!-- Screw Left -->
120+
<g transform="translate(1.625 9.5) rotate(45)">
121+
<use href="#screw" />
122+
</g>
123+
<!-- Screw Right -->
124+
<g transform="translate(43.375 9.5) rotate(45)">
125+
<use href="#screw" />
126+
</g>
127+
<!-- Tip -->
128+
<g
129+
id="tip"
130+
transform="translate(${tipOffSetX} 0)"
131+
@mousedown=${this.down}
132+
@touchstart=${this.down}
133+
@touchmove=${this.touchMove}
134+
@touchend=${this.up}
135+
@keydown=${this.down}
136+
@keyup=${this.up}
137+
@click="${this.focusInput}"
138+
>
139+
<rect x="19.75" y="8.6" width="5.5" height="1.8" />
140+
<rect
141+
x="16.5"
142+
y="0"
143+
width="12"
144+
height="19"
145+
fill="url(#tipGradient)"
146+
stroke-width="2.6518"
147+
rx=".1"
148+
ry=".1"
149+
/>
150+
<rect x="22.2" y="0" width=".6" height="19" fill="#efefef" />
151+
</g>
152+
</g>
153+
</svg>
154+
`;
155+
}
156+
157+
connectedCallback() {
158+
super.connectedCallback();
159+
window.addEventListener('mouseup', this.up);
160+
window.addEventListener('mousemove', this.mouseMove);
161+
window.addEventListener('mouseleave', this.up);
162+
}
163+
164+
disconnectedCallback() {
165+
super.disconnectedCallback();
166+
window.removeEventListener('mouseup', this.up);
167+
window.removeEventListener('mousemove', this.mouseMove);
168+
window.removeEventListener('mouseleave', this.up);
169+
}
170+
171+
private focusInput() {
172+
const inputEl: HTMLInputElement | null | undefined = this.shadowRoot?.querySelector(
173+
'.hide-input'
174+
);
175+
inputEl?.focus();
176+
}
177+
178+
private down(): void {
179+
if (!this.isPressed) {
180+
this.updateCaseDisplayProperties();
181+
}
182+
this.isPressed = true;
183+
}
184+
185+
private up = () => {
186+
if (this.isPressed) {
187+
this.isPressed = false;
188+
}
189+
};
190+
191+
private updateCaseDisplayProperties(): void {
192+
const element = this.shadowRoot?.querySelector<SVGRectElement>('#sliderCase');
193+
if (element) {
194+
this.pageToLocalTransformationMatrix = element.getScreenCTM()?.inverse() || null;
195+
}
196+
197+
// Handle zooming in the storybook
198+
const zoom = getComputedStyle(window.document.body)?.zoom;
199+
if (zoom !== undefined) {
200+
this.zoom = Number(zoom);
201+
} else {
202+
this.zoom = 1;
203+
}
204+
}
205+
206+
private onInputValueChange(event: KeyboardEvent): void {
207+
const target = event.target as HTMLInputElement;
208+
if (target.value) {
209+
this.updateValue(Number(target.value));
210+
}
211+
}
212+
213+
private mouseMove = (event: MouseEvent) => {
214+
if (this.isPressed) {
215+
this.updateValueFromXCoordinate(new DOMPointReadOnly(event.pageX, event.pageY));
216+
}
217+
};
218+
219+
private touchMove(event: TouchEvent): void {
220+
if (this.isPressed) {
221+
if (event.targetTouches.length > 0) {
222+
const touchTarget = event.targetTouches[0];
223+
this.updateValueFromXCoordinate(new DOMPointReadOnly(touchTarget.pageX, touchTarget.pageY));
224+
}
225+
}
226+
}
227+
228+
private updateValueFromXCoordinate(position: DOMPointReadOnly): void {
229+
if (this.pageToLocalTransformationMatrix) {
230+
// Handle zoom first, the transformation matrix does not take that into account
231+
let localPosition = new DOMPointReadOnly(position.x / this.zoom, position.y / this.zoom);
232+
// Converts the point from the page coordinate space to the #caseRect coordinate space
233+
// It also translates the units from pixels to millimeters!
234+
localPosition = localPosition.matrixTransform(this.pageToLocalTransformationMatrix);
235+
const caseBorderWidth = 7.5;
236+
const tipPositionXinMM = localPosition.x - caseBorderWidth;
237+
const mmPerIncrement = 30 / (this.max - this.min);
238+
this.updateValue(Math.round(tipPositionXinMM / mmPerIncrement));
239+
}
240+
}
241+
242+
private updateValue(value: number) {
243+
this.value = clamp(this.min, this.max, value);
244+
this.dispatchEvent(new InputEvent('input', { detail: this.value }));
245+
}
246+
}

src/utils/clamp.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const clamp = (min: number, max: number, value: number): number => {
2+
const clampedValue = Math.min(value, max);
3+
return Math.max(clampedValue, min);
4+
};

0 commit comments

Comments
 (0)