diff --git a/src/index.ts b/src/index.ts index efcdada..b19b009 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export { SlideSwitchElement } from './slide-switch-element'; export { HCSR04Element } from './hc-sr04-element'; export { LCD2004Element } from './lcd2004-element'; export { AnalogJoystickElement } from './analog-joystick-element'; +export { SlidePotentiometerElement } from './slide-potentiometer-element'; export { IRReceiverElement } from './ir-receiver-element'; export { IRRemoteElement } from './ir-remote-element'; export { PIRMotionSensorElement } from './pir-motion-sensor-element'; diff --git a/src/potentiometer-element.ts b/src/potentiometer-element.ts index b780a27..70d4024 100644 --- a/src/potentiometer-element.ts +++ b/src/potentiometer-element.ts @@ -1,6 +1,7 @@ import { css, customElement, html, LitElement, property } from 'lit-element'; import { styleMap } from 'lit-html/directives/style-map'; import { analog, ElementPin } from './pin'; +import { clamp } from './utils/clamp'; interface Point { x: number; @@ -54,10 +55,6 @@ export class PotentiometerElement extends LitElement { `; } - clamp(min: number, max: number, value: number): number { - return Math.min(Math.max(value, min), max); - } - mapToMinMax(value: number, min: number, max: number): number { return value * (max - min) + min; } @@ -67,7 +64,7 @@ export class PotentiometerElement extends LitElement { } render() { - const percent = this.clamp(0, 1, this.percentFromMinMax(this.value, this.min, this.max)); + const percent = clamp(0, 1, this.percentFromMinMax(this.value, this.min, this.max)); const knobDeg = (this.endDegree - this.startDegree) * percent + this.startDegree; return html` @@ -222,7 +219,7 @@ export class PotentiometerElement extends LitElement { deg -= 360; } - deg = this.clamp(this.startDegree, this.endDegree, deg); + deg = clamp(this.startDegree, this.endDegree, deg); const percent = this.percentFromMinMax(deg, this.startDegree, this.endDegree); const value = this.mapToMinMax(percent, this.min, this.max); @@ -230,7 +227,7 @@ export class PotentiometerElement extends LitElement { } private updateValue(value: number) { - const clamped = this.clamp(this.min, this.max, value); + const clamped = clamp(this.min, this.max, value); const updated = Math.round(clamped / this.step) * this.step; this.value = Math.round(updated * 100) / 100; this.dispatchEvent(new InputEvent('input', { detail: this.value })); diff --git a/src/react-types.ts b/src/react-types.ts index 6c6e8ba..aea0aa9 100644 --- a/src/react-types.ts +++ b/src/react-types.ts @@ -24,6 +24,7 @@ import { SlideSwitchElement } from './slide-switch-element'; import { HCSR04Element } from './hc-sr04-element'; import { LCD2004Element } from './lcd2004-element'; import { AnalogJoystickElement } from './analog-joystick-element'; +import { SlidePotentiometerElement } from './slide-potentiometer-element'; import { IRReceiverElement } from './ir-receiver-element'; import { IRRemoteElement } from './ir-remote-element'; import { PIRMotionSensorElement } from './pir-motion-sensor-element'; @@ -57,6 +58,7 @@ declare global { 'wokwi-hc-sr04': WokwiElement; 'wokwi-lcd2004': WokwiElement; 'wokwi-analog-joystick': WokwiElement; + 'wokwi-slide-potentiometer': WokwiElement; 'wokwi-ir-receiver': WokwiElement; 'wokwi-ir-remote': WokwiElement; 'wokwi-pir-motion-sensor': WokwiElement; diff --git a/src/slide-potentiometer-element.stories.ts b/src/slide-potentiometer-element.stories.ts new file mode 100644 index 0000000..420d155 --- /dev/null +++ b/src/slide-potentiometer-element.stories.ts @@ -0,0 +1,20 @@ +import { html } from 'lit-html'; +import { action } from '@storybook/addon-actions'; +import './slide-potentiometer-element'; + +export default { + title: 'Slide Potentiometer', + component: 'wokwi-slide-potentiometer', +}; + +const Template = ({ degrees = 0 }) => html`
+ +
`; + +export const Default = Template.bind({}); +Default.args = {}; + +export const Rotated = Template.bind({}); +Rotated.args = { ...Default.args, degrees: 90 }; diff --git a/src/slide-potentiometer-element.ts b/src/slide-potentiometer-element.ts new file mode 100644 index 0000000..3e6ffa6 --- /dev/null +++ b/src/slide-potentiometer-element.ts @@ -0,0 +1,246 @@ +import { css, customElement, html, LitElement, property, svg } from 'lit-element'; +import { analog, ElementPin } from './pin'; +import { clamp } from './utils/clamp'; + +@customElement('wokwi-slide-potentiometer') +export class SlidePotentiometerElement extends LitElement { + @property() value = 0; + @property() min = 0; + @property() max = 100; + @property() step = 2; + readonly pinInfo: ElementPin[] = [ + { name: 'VCC', x: 1, y: 43, number: 1, signals: [{ type: 'power', signal: 'VCC' }] }, + { name: 'SIG', x: 1, y: 66.5, number: 2, signals: [analog(0)] }, + { name: 'GND', x: 207, y: 43, number: 3, signals: [{ type: 'power', signal: 'GND' }] }, + ]; + private isPressed = false; + private zoom = 1; + private pageToLocalTransformationMatrix: DOMMatrix | null = null; + + static get styles() { + return css` + .hide-input { + position: absolute; + clip: rect(0 0 0 0); + width: 1px; + height: 1px; + margin: -1px; + } + input:focus + svg #tip { + /* some style to add when the element has focus */ + filter: url(#outline); + } + `; + } + + render() { + const { value, min: minValue, max: maxValue } = this; + const tipTravelInMM = 30; + // Tip is centered by default + const tipBaseOffsetX = -(tipTravelInMM / 2); + const tipMovementX = (value / (maxValue - minValue)) * tipTravelInMM; + const tipOffSetX = tipMovementX + tipBaseOffsetX; + return html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('mouseup', this.up); + window.addEventListener('mousemove', this.mouseMove); + window.addEventListener('mouseleave', this.up); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('mouseup', this.up); + window.removeEventListener('mousemove', this.mouseMove); + window.removeEventListener('mouseleave', this.up); + } + + private focusInput() { + const inputEl: HTMLInputElement | null | undefined = this.shadowRoot?.querySelector( + '.hide-input' + ); + inputEl?.focus(); + } + + private down(): void { + if (!this.isPressed) { + this.updateCaseDisplayProperties(); + } + this.isPressed = true; + } + + private up = () => { + if (this.isPressed) { + this.isPressed = false; + } + }; + + private updateCaseDisplayProperties(): void { + const element = this.shadowRoot?.querySelector('#sliderCase'); + if (element) { + this.pageToLocalTransformationMatrix = element.getScreenCTM()?.inverse() || null; + } + + // Handle zooming in the storybook + const zoom = getComputedStyle(window.document.body)?.zoom; + if (zoom !== undefined) { + this.zoom = Number(zoom); + } else { + this.zoom = 1; + } + } + + private onInputValueChange(event: KeyboardEvent): void { + const target = event.target as HTMLInputElement; + if (target.value) { + this.updateValue(Number(target.value)); + } + } + + private mouseMove = (event: MouseEvent) => { + if (this.isPressed) { + this.updateValueFromXCoordinate(new DOMPointReadOnly(event.pageX, event.pageY)); + } + }; + + private touchMove(event: TouchEvent): void { + if (this.isPressed) { + if (event.targetTouches.length > 0) { + const touchTarget = event.targetTouches[0]; + this.updateValueFromXCoordinate(new DOMPointReadOnly(touchTarget.pageX, touchTarget.pageY)); + } + } + } + + private updateValueFromXCoordinate(position: DOMPointReadOnly): void { + if (this.pageToLocalTransformationMatrix) { + // Handle zoom first, the transformation matrix does not take that into account + let localPosition = new DOMPointReadOnly(position.x / this.zoom, position.y / this.zoom); + // Converts the point from the page coordinate space to the #caseRect coordinate space + // It also translates the units from pixels to millimeters! + localPosition = localPosition.matrixTransform(this.pageToLocalTransformationMatrix); + const caseBorderWidth = 7.5; + const tipPositionXinMM = localPosition.x - caseBorderWidth; + const mmPerIncrement = 30 / (this.max - this.min); + this.updateValue(Math.round(tipPositionXinMM / mmPerIncrement)); + } + } + + private updateValue(value: number) { + this.value = clamp(this.min, this.max, value); + this.dispatchEvent(new InputEvent('input', { detail: this.value })); + } +} diff --git a/src/utils/clamp.ts b/src/utils/clamp.ts new file mode 100644 index 0000000..f64045f --- /dev/null +++ b/src/utils/clamp.ts @@ -0,0 +1,4 @@ +export const clamp = (min: number, max: number, value: number): number => { + const clampedValue = Math.min(value, max); + return Math.max(clampedValue, min); +};