@jjlmoya/utils-hardware 1.21.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/mouseScrollTest/bibliography.astro +14 -0
- package/src/tool/mouseScrollTest/bibliography.ts +16 -0
- package/src/tool/mouseScrollTest/component.astro +150 -0
- package/src/tool/mouseScrollTest/entry.ts +29 -0
- package/src/tool/mouseScrollTest/i18n/de.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/en.ts +253 -0
- package/src/tool/mouseScrollTest/i18n/es.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/fr.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/id.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/it.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/ja.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/ko.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/nl.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/pl.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/pt.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/ru.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/sv.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/tr.ts +248 -0
- package/src/tool/mouseScrollTest/i18n/zh.ts +248 -0
- package/src/tool/mouseScrollTest/index.ts +9 -0
- package/src/tool/mouseScrollTest/logic.ts +277 -0
- package/src/tool/mouseScrollTest/mouse-scroll-test.css +610 -0
- package/src/tool/mouseScrollTest/seo.astro +15 -0
- package/src/tool/mouseScrollTest/ui.ts +34 -0
- package/src/tools.ts +2 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
type ScrollAxis = 'vertical' | 'horizontal';
|
|
2
|
+
type ScrollDirection = 'up' | 'down' | 'left' | 'right' | 'none';
|
|
3
|
+
type ScrollState = 'clean' | 'reversal' | 'jitter';
|
|
4
|
+
|
|
5
|
+
interface ScrollSample { axis: ScrollAxis; direction: ScrollDirection; delta: number; time: number; state: ScrollState; }
|
|
6
|
+
|
|
7
|
+
interface AxisState {
|
|
8
|
+
lastDirection: ScrollDirection;
|
|
9
|
+
lastTime: number;
|
|
10
|
+
run: number;
|
|
11
|
+
longestRun: number;
|
|
12
|
+
reversals: number;
|
|
13
|
+
jitter: number;
|
|
14
|
+
total: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface MouseScrollElements {
|
|
18
|
+
capture: HTMLElement | null;
|
|
19
|
+
score: HTMLElement | null;
|
|
20
|
+
status: HTMLElement | null;
|
|
21
|
+
total: HTMLElement | null;
|
|
22
|
+
reversals: HTMLElement | null;
|
|
23
|
+
longestRun: HTMLElement | null;
|
|
24
|
+
lastDelta: HTMLElement | null;
|
|
25
|
+
dominant: HTMLElement | null;
|
|
26
|
+
sensitivity: HTMLInputElement | null;
|
|
27
|
+
sensitivityValue: HTMLElement | null;
|
|
28
|
+
reset: HTMLElement | null;
|
|
29
|
+
log: HTMLElement | null;
|
|
30
|
+
verticalNeedle: HTMLElement | null;
|
|
31
|
+
horizontalNeedle: HTMLElement | null;
|
|
32
|
+
verticalMeter: HTMLElement | null;
|
|
33
|
+
horizontalMeter: HTMLElement | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface MouseScrollLabels {
|
|
37
|
+
statusIdle: string; statusClean: string; statusWarning: string; statusMixed: string;
|
|
38
|
+
upward: string; downward: string; leftward: string; rightward: string; noDirection: string;
|
|
39
|
+
sensitivityUnit: string; emptyLog: string; cleanEvent: string; reversalEvent: string; jitterEvent: string;
|
|
40
|
+
verticalAxis: string; horizontalAxis: string; noDataValue: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const REVERSAL_WINDOW_MS = 180;
|
|
44
|
+
const MAX_LOG_ITEMS = 16;
|
|
45
|
+
|
|
46
|
+
export class MouseScrollTester {
|
|
47
|
+
private sensitivity = 12;
|
|
48
|
+
private readonly samples: ScrollSample[] = [];
|
|
49
|
+
private readonly axes: Record<ScrollAxis, AxisState> = {
|
|
50
|
+
vertical: this.createAxisState(),
|
|
51
|
+
horizontal: this.createAxisState(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly elements: MouseScrollElements,
|
|
56
|
+
private readonly labels: MouseScrollLabels,
|
|
57
|
+
) {
|
|
58
|
+
this.sensitivity = Number(elements.sensitivity?.value ?? 12);
|
|
59
|
+
this.bindEvents();
|
|
60
|
+
this.render();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private bindEvents() {
|
|
64
|
+
this.elements.capture?.addEventListener('wheel', (event) => this.recordWheel(event), { passive: false });
|
|
65
|
+
this.elements.capture?.addEventListener('click', () => this.elements.capture?.focus());
|
|
66
|
+
this.elements.capture?.addEventListener('focusin', () => this.setCaptureState(true));
|
|
67
|
+
this.elements.capture?.addEventListener('pointerleave', () => {
|
|
68
|
+
if (document.activeElement !== this.elements.capture) this.setCaptureState(false);
|
|
69
|
+
});
|
|
70
|
+
this.elements.capture?.addEventListener('blur', () => this.setCaptureState(false));
|
|
71
|
+
this.elements.sensitivity?.addEventListener('input', () => {
|
|
72
|
+
this.sensitivity = Number(this.elements.sensitivity?.value ?? 12);
|
|
73
|
+
this.recalculate();
|
|
74
|
+
this.render();
|
|
75
|
+
});
|
|
76
|
+
this.elements.reset?.addEventListener('click', () => this.reset());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private recordWheel(event: WheelEvent) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
this.elements.capture?.focus();
|
|
82
|
+
this.setCaptureState(true);
|
|
83
|
+
|
|
84
|
+
const axis: ScrollAxis = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? 'horizontal' : 'vertical';
|
|
85
|
+
const delta = axis === 'horizontal' ? event.deltaX : event.deltaY;
|
|
86
|
+
const magnitude = Math.abs(delta);
|
|
87
|
+
if (magnitude < 0.2) return;
|
|
88
|
+
|
|
89
|
+
const direction = this.getDirection(axis, delta);
|
|
90
|
+
const state = this.classify(axis, direction, magnitude, performance.now());
|
|
91
|
+
const sample: ScrollSample = { axis, direction, delta, time: performance.now(), state };
|
|
92
|
+
|
|
93
|
+
this.samples.unshift(sample);
|
|
94
|
+
this.samples.length = Math.min(this.samples.length, 80);
|
|
95
|
+
this.updateAxis(axis, sample);
|
|
96
|
+
this.animateNeedles(axis, delta, state);
|
|
97
|
+
this.render();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private classify(axis: ScrollAxis, direction: ScrollDirection, magnitude: number, now: number): ScrollState {
|
|
101
|
+
const state = this.axes[axis];
|
|
102
|
+
if (magnitude < this.sensitivity) return 'jitter';
|
|
103
|
+
if (
|
|
104
|
+
state.lastDirection !== 'none' &&
|
|
105
|
+
direction !== state.lastDirection &&
|
|
106
|
+
now - state.lastTime <= REVERSAL_WINDOW_MS
|
|
107
|
+
) {
|
|
108
|
+
return 'reversal';
|
|
109
|
+
}
|
|
110
|
+
return 'clean';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private updateAxis(axis: ScrollAxis, sample: ScrollSample) {
|
|
114
|
+
const state = this.axes[axis];
|
|
115
|
+
state.total++;
|
|
116
|
+
|
|
117
|
+
if (sample.state === 'reversal') {
|
|
118
|
+
state.reversals++;
|
|
119
|
+
state.run = 1;
|
|
120
|
+
} else if (sample.direction === state.lastDirection) {
|
|
121
|
+
state.run++;
|
|
122
|
+
} else {
|
|
123
|
+
state.run = 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (sample.state === 'jitter') state.jitter++;
|
|
127
|
+
state.longestRun = Math.max(state.longestRun, state.run);
|
|
128
|
+
state.lastDirection = sample.direction;
|
|
129
|
+
state.lastTime = sample.time;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private recalculate() {
|
|
133
|
+
const oldSamples = [...this.samples].reverse();
|
|
134
|
+
this.reset(false);
|
|
135
|
+
oldSamples.forEach((sample) => {
|
|
136
|
+
const state = this.classify(sample.axis, sample.direction, Math.abs(sample.delta), sample.time);
|
|
137
|
+
const nextSample = { ...sample, state };
|
|
138
|
+
this.samples.unshift(nextSample);
|
|
139
|
+
this.updateAxis(nextSample.axis, nextSample);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private reset(render = true) {
|
|
144
|
+
this.samples.length = 0;
|
|
145
|
+
this.axes.vertical = this.createAxisState();
|
|
146
|
+
this.axes.horizontal = this.createAxisState();
|
|
147
|
+
this.setMeter(this.elements.verticalMeter, 0);
|
|
148
|
+
this.setMeter(this.elements.horizontalMeter, 0);
|
|
149
|
+
if (render) this.render();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private createAxisState(): AxisState {
|
|
153
|
+
return { lastDirection: 'none', lastTime: 0, run: 0, longestRun: 0, reversals: 0, jitter: 0, total: 0 };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private getDirection(axis: ScrollAxis, delta: number): ScrollDirection {
|
|
157
|
+
if (axis === 'vertical') return delta < 0 ? 'up' : 'down';
|
|
158
|
+
return delta < 0 ? 'left' : 'right';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private getTotals() {
|
|
162
|
+
const total = this.axes.vertical.total + this.axes.horizontal.total;
|
|
163
|
+
const reversals = this.axes.vertical.reversals + this.axes.horizontal.reversals;
|
|
164
|
+
const jitter = this.axes.vertical.jitter + this.axes.horizontal.jitter;
|
|
165
|
+
const longestRun = Math.max(this.axes.vertical.longestRun, this.axes.horizontal.longestRun);
|
|
166
|
+
return { total, reversals, jitter, longestRun };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private getScore() {
|
|
170
|
+
const { total, reversals, jitter } = this.getTotals();
|
|
171
|
+
if (total === 0) return 100;
|
|
172
|
+
const reversalPenalty = reversals * 18;
|
|
173
|
+
const jitterPenalty = Math.round((jitter / total) * 22);
|
|
174
|
+
return Math.max(0, Math.min(100, 100 - reversalPenalty - jitterPenalty));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private render() {
|
|
178
|
+
const totals = this.getTotals();
|
|
179
|
+
const score = this.getScore();
|
|
180
|
+
const last = this.samples[0];
|
|
181
|
+
|
|
182
|
+
this.setText(this.elements.score, `${score}%`);
|
|
183
|
+
this.setText(this.elements.total, String(totals.total));
|
|
184
|
+
this.setText(this.elements.reversals, String(totals.reversals));
|
|
185
|
+
this.setText(this.elements.longestRun, String(totals.longestRun));
|
|
186
|
+
this.setText(this.elements.lastDelta, last ? this.formatDelta(last.delta) : this.labels.noDataValue);
|
|
187
|
+
this.setText(this.elements.dominant, this.getDominantDirection());
|
|
188
|
+
this.setText(this.elements.sensitivityValue, String(this.sensitivity));
|
|
189
|
+
this.renderStatus(totals.total, totals.reversals, totals.jitter);
|
|
190
|
+
this.renderLog();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private renderStatus(total: number, reversals: number, jitter: number) {
|
|
194
|
+
if (!this.elements.status) return;
|
|
195
|
+
if (total === 0) {
|
|
196
|
+
this.elements.status.textContent = this.labels.statusIdle;
|
|
197
|
+
this.elements.status.dataset.state = 'idle';
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (reversals > 0) {
|
|
201
|
+
this.elements.status.textContent = this.labels.statusWarning;
|
|
202
|
+
this.elements.status.dataset.state = 'warning';
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (jitter > total * 0.35) {
|
|
206
|
+
this.elements.status.textContent = this.labels.statusMixed;
|
|
207
|
+
this.elements.status.dataset.state = 'mixed';
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.elements.status.textContent = this.labels.statusClean;
|
|
211
|
+
this.elements.status.dataset.state = 'clean';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private renderLog() {
|
|
215
|
+
if (!this.elements.log) return;
|
|
216
|
+
if (this.samples.length === 0) {
|
|
217
|
+
this.elements.log.innerHTML = `<li class="mst-empty">${this.labels.emptyLog}</li>`;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.elements.log.innerHTML = this.samples
|
|
221
|
+
.slice(0, MAX_LOG_ITEMS)
|
|
222
|
+
.map((sample) => {
|
|
223
|
+
const stateLabel = this.getStateLabel(sample.state);
|
|
224
|
+
const axisLabel = sample.axis === 'vertical' ? this.labels.verticalAxis : this.labels.horizontalAxis;
|
|
225
|
+
return `<li class="mst-log-item ${sample.state}"><b>${this.getDirectionLabel(sample.direction)}</b><span>${axisLabel} ${this.formatDelta(sample.delta)}</span><em>${stateLabel}</em></li>`;
|
|
226
|
+
})
|
|
227
|
+
.join('');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private animateNeedles(axis: ScrollAxis, delta: number, state: ScrollState) {
|
|
231
|
+
const needle = axis === 'vertical' ? this.elements.verticalNeedle : this.elements.horizontalNeedle;
|
|
232
|
+
const meter = axis === 'vertical' ? this.elements.verticalMeter : this.elements.horizontalMeter;
|
|
233
|
+
const normalized = Math.max(-1, Math.min(1, delta / 120));
|
|
234
|
+
const offset = normalized * 42;
|
|
235
|
+
if (needle) {
|
|
236
|
+
needle.style.setProperty('--mst-needle-offset', `${offset}%`);
|
|
237
|
+
needle.dataset.state = state;
|
|
238
|
+
window.setTimeout(() => {
|
|
239
|
+
needle.style.setProperty('--mst-needle-offset', '0%');
|
|
240
|
+
needle.dataset.state = '';
|
|
241
|
+
}, 170);
|
|
242
|
+
}
|
|
243
|
+
this.setMeter(meter, Math.min(100, Math.abs(delta)));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private setMeter(element: HTMLElement | null, value: number) {
|
|
247
|
+
element?.style.setProperty('--mst-meter', `${value}%`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private getDominantDirection() {
|
|
251
|
+
const last = this.samples[0];
|
|
252
|
+
return last ? this.getDirectionLabel(last.direction) : this.labels.noDirection;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private getDirectionLabel(direction: ScrollDirection) {
|
|
256
|
+
const labels: Record<ScrollDirection, string> = { up: this.labels.upward, down: this.labels.downward, left: this.labels.leftward, right: this.labels.rightward, none: this.labels.noDirection };
|
|
257
|
+
return labels[direction];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private getStateLabel(state: ScrollState) {
|
|
261
|
+
const labels: Record<ScrollState, string> = { clean: this.labels.cleanEvent, reversal: this.labels.reversalEvent, jitter: this.labels.jitterEvent };
|
|
262
|
+
return labels[state];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private formatDelta(delta: number) {
|
|
266
|
+
const rounded = Math.round(delta * 10) / 10;
|
|
267
|
+
return `${rounded > 0 ? '+' : ''}${rounded}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private setText(element: HTMLElement | null, value: string) {
|
|
271
|
+
if (element) element.textContent = value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private setCaptureState(active: boolean) {
|
|
275
|
+
if (this.elements.capture) this.elements.capture.dataset.captureState = active ? 'active' : 'locked';
|
|
276
|
+
}
|
|
277
|
+
}
|