@jjlmoya/utils-games-development 1.60.0 → 1.62.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 +2 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/spanish_leakage.test.ts +25 -3
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/gameUIAccessibilityTester/bibliography.astro +6 -0
- package/src/tool/gameUIAccessibilityTester/bibliography.ts +10 -0
- package/src/tool/gameUIAccessibilityTester/component.astro +210 -0
- package/src/tool/gameUIAccessibilityTester/controller-actions.ts +92 -0
- package/src/tool/gameUIAccessibilityTester/controller.ts +237 -0
- package/src/tool/gameUIAccessibilityTester/dom-views.ts +193 -0
- package/src/tool/gameUIAccessibilityTester/entry.ts +28 -0
- package/src/tool/gameUIAccessibilityTester/evaluator.ts +43 -0
- package/src/tool/gameUIAccessibilityTester/game-ui-accessibility-stress-tester.css +982 -0
- package/src/tool/gameUIAccessibilityTester/i18n/de.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/en.ts +199 -0
- package/src/tool/gameUIAccessibilityTester/i18n/es.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/fr.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/id.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/it.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ja.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ko.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/nl.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pl.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pt.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ru.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/sv.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/tr.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/zh.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/index.ts +11 -0
- package/src/tool/gameUIAccessibilityTester/logic.test.ts +57 -0
- package/src/tool/gameUIAccessibilityTester/logic.ts +157 -0
- package/src/tool/gameUIAccessibilityTester/report.ts +59 -0
- package/src/tool/gameUIAccessibilityTester/seo.astro +15 -0
- package/src/tool/gameUIAccessibilityTester/storage.ts +34 -0
- package/src/tool/gameUIAccessibilityTester/ui.ts +80 -0
- package/src/tools.ts +2 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { evaluatePair, type PairDiagnostic } from './evaluator';
|
|
2
|
+
import {
|
|
3
|
+
copyOriginal,
|
|
4
|
+
loadImageFile,
|
|
5
|
+
renderSimulation,
|
|
6
|
+
sampleAtFraction,
|
|
7
|
+
sampleCanvas,
|
|
8
|
+
} from './dom-views';
|
|
9
|
+
import { fromHex, toHex, type RGBColor } from './logic';
|
|
10
|
+
import { loadSettings, saveSettings, type StoredSettings } from './storage';
|
|
11
|
+
import type { GameUiAccessibilityTesterUI } from './ui';
|
|
12
|
+
import { bindActions } from './controller-actions';
|
|
13
|
+
|
|
14
|
+
export interface TesterState {
|
|
15
|
+
root: HTMLElement;
|
|
16
|
+
ui: GameUiAccessibilityTesterUI;
|
|
17
|
+
settings: StoredSettings;
|
|
18
|
+
source: HTMLCanvasElement | null;
|
|
19
|
+
samples: [RGBColor | null, RGBColor | null];
|
|
20
|
+
positions: [{ left: number; top: number }, { left: number; top: number }];
|
|
21
|
+
activeSample: 0 | 1;
|
|
22
|
+
findings: string[];
|
|
23
|
+
frame: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const FALLBACK_SETTINGS: StoredSettings = {
|
|
27
|
+
mode: 'deuteranopia', compare: 'side', blur: 0, downscale: 1, zoom: 1, heatmap: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function required<T extends Element>(root: ParentNode, selector: string): T {
|
|
31
|
+
const element = root.querySelector<T>(selector);
|
|
32
|
+
if (!element) throw new Error(`Missing tester element ${selector}`);
|
|
33
|
+
return element;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readUi(root: HTMLElement): GameUiAccessibilityTesterUI {
|
|
37
|
+
const script = required<HTMLScriptElement>(root, '[data-ui]');
|
|
38
|
+
return JSON.parse(script.textContent ?? '{}') as GameUiAccessibilityTesterUI;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setStatus(state: TesterState, message: string): void {
|
|
42
|
+
required<HTMLElement>(state.root, '[data-status]').textContent = message;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setExportsEnabled(state: TesterState, enabled: boolean): void {
|
|
46
|
+
state.root.querySelectorAll<HTMLButtonElement>('[data-export-sheet], [data-export-report]')
|
|
47
|
+
.forEach((button) => { button.disabled = !enabled; });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setActive(root: HTMLElement, selector: string, value: string): void {
|
|
51
|
+
root.querySelectorAll<HTMLButtonElement>(selector).forEach((button) => {
|
|
52
|
+
const active = button.value === value;
|
|
53
|
+
button.classList.toggle('is-active', active);
|
|
54
|
+
button.setAttribute('aria-pressed', String(active));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function diagnosticLabel(state: TesterState, diagnostic: PairDiagnostic): string {
|
|
59
|
+
if (diagnostic.status === 'review') return state.ui.statusReview;
|
|
60
|
+
if (diagnostic.status === 'watch') return state.ui.statusWatch;
|
|
61
|
+
return state.ui.statusStrong;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderMetrics(state: TesterState): void {
|
|
65
|
+
const [first, second] = state.samples;
|
|
66
|
+
if (!first || !second) return;
|
|
67
|
+
const diagnostic = evaluatePair(first, second, state.settings.mode);
|
|
68
|
+
required(state.root, '[data-original-contrast]').textContent = `${diagnostic.originalContrast.toFixed(2)}:1`;
|
|
69
|
+
required(state.root, '[data-simulated-contrast]').textContent = `${diagnostic.simulatedContrast.toFixed(2)}:1`;
|
|
70
|
+
required(state.root, '[data-retained]').textContent = `${diagnostic.retained.toFixed(0)}%`;
|
|
71
|
+
const badge = required(state.root, '[data-diagnostic]');
|
|
72
|
+
badge.textContent = diagnosticLabel(state, diagnostic);
|
|
73
|
+
badge.setAttribute('data-level', diagnostic.status);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderNow(state: TesterState): void {
|
|
77
|
+
state.frame = 0;
|
|
78
|
+
if (!state.source) return;
|
|
79
|
+
const original = required<HTMLCanvasElement>(state.root, '[data-original-canvas]');
|
|
80
|
+
const simulated = required<HTMLCanvasElement>(state.root, '[data-simulated-canvas]');
|
|
81
|
+
copyOriginal(state.source, original);
|
|
82
|
+
renderSimulation({
|
|
83
|
+
source: state.source,
|
|
84
|
+
destination: simulated,
|
|
85
|
+
mode: state.settings.mode,
|
|
86
|
+
blur: state.settings.blur,
|
|
87
|
+
downscale: state.settings.downscale,
|
|
88
|
+
heatmap: state.settings.heatmap,
|
|
89
|
+
});
|
|
90
|
+
state.root.dataset.compare = state.settings.compare;
|
|
91
|
+
state.root.dataset.zoom = String(state.settings.zoom);
|
|
92
|
+
renderMetrics(state);
|
|
93
|
+
saveSettings(state.settings);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scheduleRender(state: TesterState): void {
|
|
97
|
+
if (state.frame) cancelAnimationFrame(state.frame);
|
|
98
|
+
state.frame = requestAnimationFrame(() => renderNow(state));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function placeMarker(state: TesterState, index: 0 | 1): void {
|
|
102
|
+
const marker = required<HTMLElement>(state.root, `[data-marker="${index}"]`);
|
|
103
|
+
const position = state.positions[index];
|
|
104
|
+
marker.style.setProperty('--marker-x', `${position.left * 100}%`);
|
|
105
|
+
marker.style.setProperty('--marker-y', `${position.top * 100}%`);
|
|
106
|
+
marker.hidden = false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderSample(state: TesterState, index: 0 | 1): void {
|
|
110
|
+
const color = state.samples[index];
|
|
111
|
+
if (!color) return;
|
|
112
|
+
const chip = required<HTMLElement>(state.root, `[data-swatch="${index}"]`);
|
|
113
|
+
chip.style.backgroundColor = toHex(color);
|
|
114
|
+
required(state.root, `[data-hex="${index}"]`).textContent = toHex(color);
|
|
115
|
+
required<HTMLInputElement>(state.root, `[data-color-input="${index}"]`).value = toHex(color);
|
|
116
|
+
placeMarker(state, index);
|
|
117
|
+
renderMetrics(state);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function selectSample(state: TesterState, index: 0 | 1): void {
|
|
121
|
+
state.activeSample = index;
|
|
122
|
+
setActive(state.root, '[data-sample-button]', String(index));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sampleFromEvent(state: TesterState, event: MouseEvent): void {
|
|
126
|
+
if (!state.source) return;
|
|
127
|
+
const canvas = required<HTMLCanvasElement>(state.root, '[data-original-canvas]');
|
|
128
|
+
const sample = sampleCanvas(canvas, event.clientX, event.clientY);
|
|
129
|
+
const index = state.activeSample;
|
|
130
|
+
state.samples[index] = sample.color;
|
|
131
|
+
state.positions[index] = { left: sample.left, top: sample.top };
|
|
132
|
+
renderSample(state, index);
|
|
133
|
+
selectSample(state, index === 0 ? 1 : 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function seedSamples(state: TesterState): void {
|
|
137
|
+
if (!state.source) return;
|
|
138
|
+
state.positions = [{ left: 0.35, top: 0.5 }, { left: 0.65, top: 0.5 }];
|
|
139
|
+
state.samples = [
|
|
140
|
+
sampleAtFraction(state.source, 0.35, 0.5),
|
|
141
|
+
sampleAtFraction(state.source, 0.65, 0.5),
|
|
142
|
+
];
|
|
143
|
+
renderSample(state, 0);
|
|
144
|
+
renderSample(state, 1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function acceptFile(state: TesterState, file: File): Promise<void> {
|
|
148
|
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) return setStatus(state, state.ui.uploadError);
|
|
149
|
+
if (file.size > 16 * 1024 * 1024) return setStatus(state, state.ui.fileTooLarge);
|
|
150
|
+
try {
|
|
151
|
+
state.source = await loadImageFile(file);
|
|
152
|
+
state.root.dataset.ready = 'true';
|
|
153
|
+
setExportsEnabled(state, true);
|
|
154
|
+
seedSamples(state);
|
|
155
|
+
renderNow(state);
|
|
156
|
+
setStatus(state, state.ui.imageReady);
|
|
157
|
+
} catch {
|
|
158
|
+
setStatus(state, state.ui.uploadError);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function bindFileControls(state: TesterState): void {
|
|
163
|
+
const input = required<HTMLInputElement>(state.root, '[data-file-input]');
|
|
164
|
+
const dropzone = required<HTMLElement>(state.root, '[data-dropzone]');
|
|
165
|
+
input.addEventListener('change', () => { if (input.files?.[0]) void acceptFile(state, input.files[0]); });
|
|
166
|
+
dropzone.addEventListener('dragover', (event) => { event.preventDefault(); dropzone.dataset.drag = 'true'; });
|
|
167
|
+
dropzone.addEventListener('dragleave', () => { delete dropzone.dataset.drag; });
|
|
168
|
+
dropzone.addEventListener('drop', (event) => {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
delete dropzone.dataset.drag;
|
|
171
|
+
if (event.dataTransfer?.files[0]) void acceptFile(state, event.dataTransfer.files[0]);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function bindChoiceButtons(state: TesterState, selector: string, key: keyof StoredSettings): void {
|
|
176
|
+
state.root.querySelectorAll<HTMLButtonElement>(selector).forEach((button) => {
|
|
177
|
+
button.addEventListener('click', () => {
|
|
178
|
+
const value = key === 'downscale' || key === 'zoom' ? Number(button.value) : button.value;
|
|
179
|
+
Object.assign(state.settings, { [key]: value });
|
|
180
|
+
setActive(state.root, selector, button.value);
|
|
181
|
+
scheduleRender(state);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function bindStressControls(state: TesterState): void {
|
|
187
|
+
const blur = required<HTMLInputElement>(state.root, '[data-blur]');
|
|
188
|
+
const split = required<HTMLInputElement>(state.root, '[data-split]');
|
|
189
|
+
const heatmap = required<HTMLInputElement>(state.root, '[data-heatmap]');
|
|
190
|
+
blur.addEventListener('input', () => { state.settings.blur = Number(blur.value); scheduleRender(state); });
|
|
191
|
+
split.addEventListener('input', () => state.root.style.setProperty('--split', `${split.value}%`));
|
|
192
|
+
heatmap.addEventListener('change', () => { state.settings.heatmap = heatmap.checked; scheduleRender(state); });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function syncStoredControls(state: TesterState): void {
|
|
196
|
+
required<HTMLInputElement>(state.root, '[data-blur]').value = String(state.settings.blur);
|
|
197
|
+
required<HTMLInputElement>(state.root, '[data-heatmap]').checked = state.settings.heatmap;
|
|
198
|
+
state.root.dataset.compare = state.settings.compare;
|
|
199
|
+
state.root.dataset.zoom = String(state.settings.zoom);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function bindSampling(state: TesterState): void {
|
|
203
|
+
required(state.root, '[data-original-canvas]').addEventListener('click', (event) => sampleFromEvent(state, event as MouseEvent));
|
|
204
|
+
state.root.querySelectorAll<HTMLButtonElement>('[data-sample-button]').forEach((button) => {
|
|
205
|
+
button.addEventListener('click', () => selectSample(state, Number(button.value) as 0 | 1));
|
|
206
|
+
});
|
|
207
|
+
state.root.querySelectorAll<HTMLInputElement>('[data-color-input]').forEach((input) => {
|
|
208
|
+
input.addEventListener('input', () => {
|
|
209
|
+
const index = Number(input.dataset.colorInput) as 0 | 1;
|
|
210
|
+
state.samples[index] = fromHex(input.value);
|
|
211
|
+
renderSample(state, index);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function mountGameUiAccessibilityTester(root: HTMLElement): void {
|
|
217
|
+
if (root.dataset.mounted === 'true') return;
|
|
218
|
+
const state: TesterState = {
|
|
219
|
+
root, ui: readUi(root), settings: loadSettings(FALLBACK_SETTINGS), source: null,
|
|
220
|
+
samples: [null, null], positions: [{ left: 0.35, top: 0.5 }, { left: 0.65, top: 0.5 }],
|
|
221
|
+
activeSample: 0, findings: [], frame: 0,
|
|
222
|
+
};
|
|
223
|
+
root.dataset.mounted = 'true';
|
|
224
|
+
bindFileControls(state);
|
|
225
|
+
bindChoiceButtons(state, '[data-mode]', 'mode');
|
|
226
|
+
bindChoiceButtons(state, '[data-compare]', 'compare');
|
|
227
|
+
bindChoiceButtons(state, '[data-downscale]', 'downscale');
|
|
228
|
+
bindChoiceButtons(state, '[data-zoom]', 'zoom');
|
|
229
|
+
bindStressControls(state);
|
|
230
|
+
bindSampling(state);
|
|
231
|
+
bindActions(state);
|
|
232
|
+
setActive(root, '[data-mode]', state.settings.mode);
|
|
233
|
+
setActive(root, '[data-compare]', state.settings.compare);
|
|
234
|
+
setActive(root, '[data-downscale]', String(state.settings.downscale));
|
|
235
|
+
setActive(root, '[data-zoom]', String(state.settings.zoom));
|
|
236
|
+
syncStoredControls(state);
|
|
237
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { simulateColor, type RGBColor, type SimulationMode } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface RenderOptions {
|
|
4
|
+
source: HTMLCanvasElement;
|
|
5
|
+
destination: HTMLCanvasElement;
|
|
6
|
+
mode: SimulationMode;
|
|
7
|
+
blur: number;
|
|
8
|
+
downscale: number;
|
|
9
|
+
heatmap: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SheetLabels {
|
|
13
|
+
title: string;
|
|
14
|
+
original: string;
|
|
15
|
+
simulated: string;
|
|
16
|
+
settings: string;
|
|
17
|
+
localOnly: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_IMAGE_EDGE = 1600;
|
|
21
|
+
|
|
22
|
+
function context(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
|
|
23
|
+
const value = canvas.getContext('2d', { willReadFrequently: true });
|
|
24
|
+
if (!value) throw new Error('Canvas 2D context unavailable');
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createCanvas(width: number, height: number): HTMLCanvasElement {
|
|
29
|
+
const canvas = document.createElement('canvas');
|
|
30
|
+
canvas.width = width;
|
|
31
|
+
canvas.height = height;
|
|
32
|
+
return canvas;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fittedSize(width: number, height: number): { width: number; height: number } {
|
|
36
|
+
const scale = Math.min(1, MAX_IMAGE_EDGE / Math.max(width, height));
|
|
37
|
+
return {
|
|
38
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
39
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadImageFile(file: File): Promise<HTMLCanvasElement> {
|
|
44
|
+
const bitmap = await createImageBitmap(file);
|
|
45
|
+
const size = fittedSize(bitmap.width, bitmap.height);
|
|
46
|
+
const canvas = createCanvas(size.width, size.height);
|
|
47
|
+
const canvasContext = context(canvas);
|
|
48
|
+
canvasContext.imageSmoothingEnabled = true;
|
|
49
|
+
canvasContext.imageSmoothingQuality = 'high';
|
|
50
|
+
canvasContext.drawImage(bitmap, 0, 0, size.width, size.height);
|
|
51
|
+
bitmap.close();
|
|
52
|
+
return canvas;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stressedSource(source: HTMLCanvasElement, scale: number, blur: number): HTMLCanvasElement {
|
|
56
|
+
const smallWidth = Math.max(1, Math.round(source.width * scale));
|
|
57
|
+
const smallHeight = Math.max(1, Math.round(source.height * scale));
|
|
58
|
+
const small = createCanvas(smallWidth, smallHeight);
|
|
59
|
+
const smallContext = context(small);
|
|
60
|
+
smallContext.imageSmoothingEnabled = true;
|
|
61
|
+
smallContext.drawImage(source, 0, 0, smallWidth, smallHeight);
|
|
62
|
+
const output = createCanvas(source.width, source.height);
|
|
63
|
+
const outputContext = context(output);
|
|
64
|
+
outputContext.imageSmoothingEnabled = scale === 1;
|
|
65
|
+
outputContext.filter = `blur(${blur}px)`;
|
|
66
|
+
outputContext.drawImage(small, 0, 0, source.width, source.height);
|
|
67
|
+
return output;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function transformPixels(image: ImageData, mode: SimulationMode): ImageData {
|
|
71
|
+
const output = new ImageData(new Uint8ClampedArray(image.data), image.width, image.height);
|
|
72
|
+
for (let index = 0; index < output.data.length; index += 4) {
|
|
73
|
+
const color = simulateColor({
|
|
74
|
+
r: output.data[index] ?? 0,
|
|
75
|
+
g: output.data[index + 1] ?? 0,
|
|
76
|
+
b: output.data[index + 2] ?? 0,
|
|
77
|
+
}, mode);
|
|
78
|
+
output.data[index] = color.r;
|
|
79
|
+
output.data[index + 1] = color.g;
|
|
80
|
+
output.data[index + 2] = color.b;
|
|
81
|
+
}
|
|
82
|
+
return output;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pixelDistance(data: Uint8ClampedArray, first: number, second: number): number {
|
|
86
|
+
const red = (data[first] ?? 0) - (data[second] ?? 0);
|
|
87
|
+
const green = (data[first + 1] ?? 0) - (data[second + 1] ?? 0);
|
|
88
|
+
const blue = (data[first + 2] ?? 0) - (data[second + 2] ?? 0);
|
|
89
|
+
return Math.sqrt(red ** 2 + green ** 2 + blue ** 2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collapsedSignal(original: ImageData, simulated: ImageData, index: number, neighbor: number): boolean {
|
|
93
|
+
const before = pixelDistance(original.data, index, neighbor);
|
|
94
|
+
const after = pixelDistance(simulated.data, index, neighbor);
|
|
95
|
+
return before >= 32 && after / before < 0.48;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function heatmapPixels(original: ImageData, simulated: ImageData): ImageData {
|
|
99
|
+
const output = new ImageData(new Uint8ClampedArray(simulated.data), simulated.width, simulated.height);
|
|
100
|
+
const row = simulated.width * 4;
|
|
101
|
+
for (let index = 0; index < output.data.length - row - 4; index += 4) {
|
|
102
|
+
const collapsed = collapsedSignal(original, simulated, index, index + 4)
|
|
103
|
+
|| collapsedSignal(original, simulated, index, index + row);
|
|
104
|
+
if (collapsed) {
|
|
105
|
+
output.data[index] = 255;
|
|
106
|
+
output.data[index + 1] = Math.round((output.data[index + 1] ?? 0) * 0.18);
|
|
107
|
+
output.data[index + 2] = 72;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return output;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function renderSimulation(options: RenderOptions): void {
|
|
114
|
+
const stressed = stressedSource(options.source, options.downscale, options.blur);
|
|
115
|
+
const sourceContext = context(stressed);
|
|
116
|
+
const original = sourceContext.getImageData(0, 0, stressed.width, stressed.height);
|
|
117
|
+
const simulated = transformPixels(original, options.mode);
|
|
118
|
+
const rendered = options.heatmap ? heatmapPixels(original, simulated) : simulated;
|
|
119
|
+
options.destination.width = stressed.width;
|
|
120
|
+
options.destination.height = stressed.height;
|
|
121
|
+
context(options.destination).putImageData(rendered, 0, 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function copyOriginal(source: HTMLCanvasElement, destination: HTMLCanvasElement): void {
|
|
125
|
+
destination.width = source.width;
|
|
126
|
+
destination.height = source.height;
|
|
127
|
+
context(destination).drawImage(source, 0, 0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function sampleCanvas(canvas: HTMLCanvasElement, clientX: number, clientY: number): {
|
|
131
|
+
color: RGBColor;
|
|
132
|
+
left: number;
|
|
133
|
+
top: number;
|
|
134
|
+
} {
|
|
135
|
+
const bounds = canvas.getBoundingClientRect();
|
|
136
|
+
const left = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width));
|
|
137
|
+
const top = Math.min(1, Math.max(0, (clientY - bounds.top) / bounds.height));
|
|
138
|
+
return { color: sampleAtFraction(canvas, left, top), left, top };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function sampleAtFraction(canvas: HTMLCanvasElement, left: number, top: number): RGBColor {
|
|
142
|
+
const x = Math.min(canvas.width - 1, Math.floor(left * canvas.width));
|
|
143
|
+
const y = Math.min(canvas.height - 1, Math.floor(top * canvas.height));
|
|
144
|
+
const data = context(canvas).getImageData(x, y, 1, 1).data;
|
|
145
|
+
return { r: data[0] ?? 0, g: data[1] ?? 0, b: data[2] ?? 0 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sheetDimensions(source: HTMLCanvasElement): { width: number; height: number; imageHeight: number } {
|
|
149
|
+
const width = Math.max(960, source.width * 2 + 72);
|
|
150
|
+
const imageHeight = Math.round((source.height / source.width) * ((width - 72) / 2));
|
|
151
|
+
return { width, height: imageHeight + 190, imageHeight };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createComparisonSheet(
|
|
155
|
+
original: HTMLCanvasElement,
|
|
156
|
+
simulated: HTMLCanvasElement,
|
|
157
|
+
labels: SheetLabels,
|
|
158
|
+
): HTMLCanvasElement {
|
|
159
|
+
const size = sheetDimensions(original);
|
|
160
|
+
const sheet = createCanvas(size.width, size.height);
|
|
161
|
+
const sheetContext = context(sheet);
|
|
162
|
+
const imageWidth = (size.width - 72) / 2;
|
|
163
|
+
sheetContext.fillStyle = '#071018';
|
|
164
|
+
sheetContext.fillRect(0, 0, size.width, size.height);
|
|
165
|
+
sheetContext.fillStyle = '#e8fbff';
|
|
166
|
+
sheetContext.font = '700 28px sans-serif';
|
|
167
|
+
sheetContext.fillText(labels.title, 24, 42);
|
|
168
|
+
sheetContext.font = '600 18px sans-serif';
|
|
169
|
+
sheetContext.fillText(labels.original, 24, 80);
|
|
170
|
+
sheetContext.fillText(labels.simulated, imageWidth + 48, 80);
|
|
171
|
+
sheetContext.drawImage(original, 24, 98, imageWidth, size.imageHeight);
|
|
172
|
+
sheetContext.drawImage(simulated, imageWidth + 48, 98, imageWidth, size.imageHeight);
|
|
173
|
+
sheetContext.font = '500 16px sans-serif';
|
|
174
|
+
sheetContext.fillText(labels.settings, 24, size.height - 48);
|
|
175
|
+
sheetContext.fillStyle = '#90aab4';
|
|
176
|
+
sheetContext.fillText(labels.localOnly, 24, size.height - 20);
|
|
177
|
+
return sheet;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function triggerDownload(blob: Blob, filename: string): void {
|
|
181
|
+
const url = URL.createObjectURL(blob);
|
|
182
|
+
const anchor = document.createElement('a');
|
|
183
|
+
anchor.href = url;
|
|
184
|
+
anchor.download = filename;
|
|
185
|
+
anchor.click();
|
|
186
|
+
URL.revokeObjectURL(url);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function downloadCanvas(canvas: HTMLCanvasElement, filename: string): void {
|
|
190
|
+
canvas.toBlob((blob) => {
|
|
191
|
+
if (blob) triggerDownload(blob, filename);
|
|
192
|
+
}, 'image/png');
|
|
193
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GamesToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { GameUiAccessibilityTesterUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { GameUiAccessibilityTesterUI };
|
|
5
|
+
|
|
6
|
+
export type GameUiAccessibilityTesterLocaleContent = ToolLocaleContent<GameUiAccessibilityTesterUI>;
|
|
7
|
+
|
|
8
|
+
export const gameUiAccessibilityTester: GamesToolEntry<GameUiAccessibilityTesterUI> = {
|
|
9
|
+
id: 'game-ui-accessibility-stress-tester',
|
|
10
|
+
icons: { bg: 'mdi:accessibility', fg: 'mdi:eye-check-outline' },
|
|
11
|
+
i18n: {
|
|
12
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
13
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
14
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
15
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
16
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
17
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
18
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
19
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
20
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
21
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
22
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
23
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
24
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
25
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
26
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
contrastRatio,
|
|
3
|
+
separationRetention,
|
|
4
|
+
simulateColor,
|
|
5
|
+
type RGBColor,
|
|
6
|
+
type SimulationMode,
|
|
7
|
+
} from './logic';
|
|
8
|
+
|
|
9
|
+
export type DiagnosticStatus = 'strong' | 'watch' | 'review';
|
|
10
|
+
|
|
11
|
+
export interface PairDiagnostic {
|
|
12
|
+
originalContrast: number;
|
|
13
|
+
simulatedContrast: number;
|
|
14
|
+
retained: number;
|
|
15
|
+
status: DiagnosticStatus;
|
|
16
|
+
simulatedFirst: RGBColor;
|
|
17
|
+
simulatedSecond: RGBColor;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function diagnosticStatus(contrast: number, retained: number): DiagnosticStatus {
|
|
21
|
+
if (contrast < 3 || retained < 45) return 'review';
|
|
22
|
+
if (contrast < 4.5 || retained < 70) return 'watch';
|
|
23
|
+
return 'strong';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function evaluatePair(
|
|
27
|
+
first: RGBColor,
|
|
28
|
+
second: RGBColor,
|
|
29
|
+
mode: SimulationMode,
|
|
30
|
+
): PairDiagnostic {
|
|
31
|
+
const simulatedFirst = simulateColor(first, mode);
|
|
32
|
+
const simulatedSecond = simulateColor(second, mode);
|
|
33
|
+
const simulatedContrast = contrastRatio(simulatedFirst, simulatedSecond);
|
|
34
|
+
const retained = separationRetention(first, second, simulatedFirst, simulatedSecond);
|
|
35
|
+
return {
|
|
36
|
+
originalContrast: contrastRatio(first, second),
|
|
37
|
+
simulatedContrast,
|
|
38
|
+
retained,
|
|
39
|
+
status: diagnosticStatus(simulatedContrast, retained),
|
|
40
|
+
simulatedFirst,
|
|
41
|
+
simulatedSecond,
|
|
42
|
+
};
|
|
43
|
+
}
|