@jjlmoya/utils-health 1.38.0 → 1.40.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 +2 -2
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/spanish_leakage.test.ts +153 -0
- package/src/tests/translation_copy.test.ts +124 -0
- package/src/tool/bloodUnitConverter/i18n/es.ts +0 -29
- package/src/tool/bloodUnitConverter/i18n/fr.ts +3 -1
- package/src/tool/bmiCalculator/i18n/es.ts +0 -4
- package/src/tool/daltonismSimulator/i18n/es.ts +0 -30
- package/src/tool/daltonismSimulator/i18n/fr.ts +6 -1
- package/src/tool/hydrationCalculator/i18n/es.ts +0 -5
- package/src/tool/hydrationCalculator/i18n/fr.ts +5 -1
- package/src/tool/peripheralVisionTrainer/i18n/fr.ts +5 -1
- package/src/tool/readingDistanceCalculator/i18n/es.ts +0 -13
- package/src/tool/readingDistanceCalculator/i18n/fr.ts +3 -1
- package/src/tool/screenDecompressionTime/i18n/es.ts +0 -22
- package/src/tool/ubeCalculator/i18n/fr.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-health",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"check": "astro check",
|
|
29
29
|
"type-check": "astro check",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
-
"preversion": "npm run lint && npm run test",
|
|
31
|
+
"preversion": "npm run lint && npm run test && npm run build",
|
|
32
32
|
"postversion": "git push && git push --tags",
|
|
33
33
|
"patch": "npm version patch",
|
|
34
34
|
"minor": "npm version minor",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
import type { KnownLocale } from '../types';
|
|
4
|
+
|
|
5
|
+
interface ExpectedCounts {
|
|
6
|
+
seo: number;
|
|
7
|
+
faq: number;
|
|
8
|
+
howTo: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function countItems(arr: unknown[] | undefined): number {
|
|
12
|
+
return arr?.length ?? 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function verifyLocaleParity(
|
|
16
|
+
entry: typeof ALL_ENTRIES[number],
|
|
17
|
+
loc: KnownLocale,
|
|
18
|
+
expected: ExpectedCounts,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const locContent = await entry.i18n[loc]?.();
|
|
21
|
+
expect(locContent, `Locale ${loc} missing content`).toBeDefined();
|
|
22
|
+
|
|
23
|
+
const locSeoCount = countItems(locContent?.seo);
|
|
24
|
+
const locFaqCount = countItems(locContent?.faq);
|
|
25
|
+
const locHowToCount = countItems(locContent?.howTo);
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
locSeoCount,
|
|
29
|
+
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
+
).toBe(expected.seo);
|
|
31
|
+
expect(
|
|
32
|
+
locFaqCount,
|
|
33
|
+
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
34
|
+
).toBe(expected.faq);
|
|
35
|
+
expect(
|
|
36
|
+
locHowToCount,
|
|
37
|
+
`Locale ${loc} HowTo steps count (${locHowToCount}) must match EN (${expected.howTo})`,
|
|
38
|
+
).toBe(expected.howTo);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
43
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
+
it('all 15 locales should have identical SEO section counts and types as English', async () => {
|
|
45
|
+
const enContent = await entry.i18n.en?.();
|
|
46
|
+
expect(enContent).toBeDefined();
|
|
47
|
+
const expected: ExpectedCounts = {
|
|
48
|
+
seo: countItems(enContent?.seo),
|
|
49
|
+
faq: countItems(enContent?.faq),
|
|
50
|
+
howTo: countItems(enContent?.howTo),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const locales = Object.keys(entry.i18n) as KnownLocale[];
|
|
54
|
+
for (const loc of locales) {
|
|
55
|
+
await verifyLocaleParity(entry, loc, expected);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const STRUCTURAL_KEYS = new Set([
|
|
5
|
+
'id',
|
|
6
|
+
'slug',
|
|
7
|
+
'url',
|
|
8
|
+
'icon',
|
|
9
|
+
'image',
|
|
10
|
+
'imageUrl',
|
|
11
|
+
'keywords',
|
|
12
|
+
'category',
|
|
13
|
+
'tags',
|
|
14
|
+
'toolId',
|
|
15
|
+
'type',
|
|
16
|
+
'locale',
|
|
17
|
+
'language',
|
|
18
|
+
'direction',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const TRANSLATABLE_KEYS = [
|
|
22
|
+
'title',
|
|
23
|
+
'description',
|
|
24
|
+
'faqTitle',
|
|
25
|
+
'faq',
|
|
26
|
+
'howTo',
|
|
27
|
+
'seo',
|
|
28
|
+
'schemas',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const SPANISH_MARKERS = [
|
|
32
|
+
['sangre', /\bsangre\b/gi],
|
|
33
|
+
['molino', /\bmolino\b/gi],
|
|
34
|
+
['grano', /\bgrano\b/gi],
|
|
35
|
+
['paladar', /\bpaladar\b/gi],
|
|
36
|
+
['cuchillas', /\bcuchillas\b/gi],
|
|
37
|
+
['trozos', /\btrozos\b/gi],
|
|
38
|
+
['frescura', /\bfrescura\b/gi],
|
|
39
|
+
['ingresa', /\bingresa\b/gi],
|
|
40
|
+
['selecciona', /\bselecciona\b/gi],
|
|
41
|
+
['herramienta', /\bherramienta\b/gi],
|
|
42
|
+
['según', /\bsegún\b/gi],
|
|
43
|
+
['después', /\bdespués\b/gi],
|
|
44
|
+
['puedes', /\bpuedes\b/gi],
|
|
45
|
+
['debes', /\bdebes\b/gi],
|
|
46
|
+
['tus', /\btus\b/gi],
|
|
47
|
+
['a la vez', /\ba la vez\b/gi],
|
|
48
|
+
['los datos', /\blos datos\b/gi],
|
|
49
|
+
['las opciones', /\blas opciones\b/gi],
|
|
50
|
+
['el resultado', /\bel resultado\b/gi],
|
|
51
|
+
['método de extracción', /\bmétodo de extracción\b/gi],
|
|
52
|
+
['uniformidad del molino', /\buniformidad del molino\b/gi],
|
|
53
|
+
['café recién tostado', /\bcafé recién tostado\b/gi],
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
type UnknownRecord = Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
function normalize(value: string): string {
|
|
59
|
+
return value
|
|
60
|
+
.replace(/<[^>]*>/g, ' ')
|
|
61
|
+
.replace(/ /gi, ' ')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim()
|
|
64
|
+
.toLocaleLowerCase('es');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isTechnicalInvariant(text: string): boolean {
|
|
68
|
+
return [
|
|
69
|
+
'data:image/svg+xml;base64',
|
|
70
|
+
'background-image: url',
|
|
71
|
+
'.layout-playground {',
|
|
72
|
+
'const samplerate =',
|
|
73
|
+
].some((pattern) => text.includes(pattern)) || (text.includes('presets') && text.includes('hz'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectString(value: string, output: string[]): void {
|
|
77
|
+
const text = normalize(value);
|
|
78
|
+
if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectObject(value: UnknownRecord, output: string[]): void {
|
|
82
|
+
Object.entries(value).forEach(([childKey, childValue]) => {
|
|
83
|
+
collectText(childValue, output, childKey);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectText(value: unknown, output: string[], key?: string): void {
|
|
88
|
+
if (key && STRUCTURAL_KEYS.has(key)) return;
|
|
89
|
+
if (typeof value === 'string') return collectString(value, output);
|
|
90
|
+
if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
|
|
91
|
+
if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadText(loader: unknown): Promise<string[]> {
|
|
95
|
+
if (typeof loader !== 'function') return [];
|
|
96
|
+
const module = await (loader as () => Promise<unknown>)();
|
|
97
|
+
const output: string[] = [];
|
|
98
|
+
if (module && typeof module === 'object') {
|
|
99
|
+
const record = module as UnknownRecord;
|
|
100
|
+
for (const key of TRANSLATABLE_KEYS) {
|
|
101
|
+
collectText(record[key], output, key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return output;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findSpanishMarkers(text: string[]): string[] {
|
|
108
|
+
const corpus = text.join(' ');
|
|
109
|
+
return SPANISH_MARKERS.flatMap(([label, pattern]) =>
|
|
110
|
+
pattern.test(corpus) ? [label] : [],
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findCopiedFragments(spanish: string[], translated: string[]): string[] {
|
|
115
|
+
const corpus = translated.join(' ');
|
|
116
|
+
return spanish
|
|
117
|
+
.filter((fragment) => fragment.length >= 80 && corpus.includes(fragment))
|
|
118
|
+
.sort((a, b) => b.length - a.length)
|
|
119
|
+
.slice(0, 3);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('Locales must not contain copied Spanish content', () => {
|
|
123
|
+
for (const entry of ALL_ENTRIES) {
|
|
124
|
+
it(`${entry.id} has no untranslated Spanish blocks`, async () => {
|
|
125
|
+
const spanish = await loadText(entry.i18n.es);
|
|
126
|
+
const failures: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
129
|
+
if (locale === 'es') continue;
|
|
130
|
+
|
|
131
|
+
const translated = await loadText(loader);
|
|
132
|
+
const copiedFragments = findCopiedFragments(spanish, translated);
|
|
133
|
+
const markerHits = findSpanishMarkers(translated);
|
|
134
|
+
|
|
135
|
+
if (copiedFragments.length > 0 || markerHits.length >= 2) {
|
|
136
|
+
const details = [
|
|
137
|
+
copiedFragments.length > 0
|
|
138
|
+
? `copied fragments: ${copiedFragments
|
|
139
|
+
.map((fragment) => JSON.stringify(fragment.slice(0, 120)))
|
|
140
|
+
.join(', ')}`
|
|
141
|
+
: '',
|
|
142
|
+
markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
|
|
143
|
+
]
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join('; ');
|
|
146
|
+
failures.push(`${locale}: ${details}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
expect(failures, failures.join('\n')).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const COPY_THRESHOLD = 0.9;
|
|
5
|
+
|
|
6
|
+
const STRUCTURAL_KEYS = new Set([
|
|
7
|
+
'@context',
|
|
8
|
+
'@type',
|
|
9
|
+
'applicationCategory',
|
|
10
|
+
'columns',
|
|
11
|
+
'highlight',
|
|
12
|
+
'icon',
|
|
13
|
+
'level',
|
|
14
|
+
'operatingSystem',
|
|
15
|
+
'position',
|
|
16
|
+
'positive',
|
|
17
|
+
'price',
|
|
18
|
+
'priceCurrency',
|
|
19
|
+
'slug',
|
|
20
|
+
'trend',
|
|
21
|
+
'type',
|
|
22
|
+
'url',
|
|
23
|
+
'value',
|
|
24
|
+
'variant',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function normalizeText(value: string): string {
|
|
28
|
+
return value
|
|
29
|
+
.replace(/<[^>]*>/g, ' ')
|
|
30
|
+
.replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
|
|
31
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
32
|
+
.replace(/[\u201c\u201d]/g, '"')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLocaleLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectText(value: unknown, path: string, parts: string[]): void {
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const normalized = normalizeText(value);
|
|
41
|
+
if (normalized.length >= 2) parts.push(normalized);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!value || typeof value !== 'object') return;
|
|
51
|
+
|
|
52
|
+
Object.entries(value).forEach(([key, child]) => {
|
|
53
|
+
if (STRUCTURAL_KEYS.has(key)) return;
|
|
54
|
+
collectText(child, `${path}.${key}`, parts);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function localeCorpus(content: unknown): string {
|
|
59
|
+
if (!content || typeof content !== 'object') return '';
|
|
60
|
+
|
|
61
|
+
const record = content as Record<string, unknown>;
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
collectText(record.title, 'title', parts);
|
|
64
|
+
collectText(record.description, 'description', parts);
|
|
65
|
+
collectText(record.faqTitle, 'faqTitle', parts);
|
|
66
|
+
collectText(record.faq, 'faq', parts);
|
|
67
|
+
collectText(record.seo, 'seo', parts);
|
|
68
|
+
collectText(record.schemas, 'schemas', parts);
|
|
69
|
+
return parts.join(' ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tokenCounts(text: string): Map<string, number> {
|
|
73
|
+
const counts = new Map<string, number>();
|
|
74
|
+
for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
|
|
75
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copySimilarity(left: string, right: string): number {
|
|
81
|
+
const leftCounts = tokenCounts(left);
|
|
82
|
+
const rightCounts = tokenCounts(right);
|
|
83
|
+
const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
84
|
+
const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
85
|
+
if (leftTotal === 0 || rightTotal === 0) return 0;
|
|
86
|
+
|
|
87
|
+
let shared = 0;
|
|
88
|
+
for (const [token, count] of leftCounts) {
|
|
89
|
+
shared += Math.min(count, rightCounts.get(token) ?? 0);
|
|
90
|
+
}
|
|
91
|
+
return (2 * shared) / (leftTotal + rightTotal);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('Locales must not copy another locale wholesale', () => {
|
|
95
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
96
|
+
it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
|
|
97
|
+
const corpora = new Map<string, string>();
|
|
98
|
+
|
|
99
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
100
|
+
if (!loader) continue;
|
|
101
|
+
corpora.set(locale, localeCorpus(await loader()));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const locales = [...corpora.keys()];
|
|
105
|
+
const violations: string[] = [];
|
|
106
|
+
|
|
107
|
+
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
108
|
+
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
109
|
+
const left = locales[leftIndex];
|
|
110
|
+
const right = locales[rightIndex];
|
|
111
|
+
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
112
|
+
|
|
113
|
+
if (similarity >= COPY_THRESHOLD) {
|
|
114
|
+
violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
|
|
@@ -173,20 +173,6 @@ export const content: ToolLocaleContent<BloodUnitConverterUI> = {
|
|
|
173
173
|
['<strong>Creatinina</strong>', 'mg/dL', 'mmol/L', '11.312 (dividir mg/dL)'],
|
|
174
174
|
],
|
|
175
175
|
},
|
|
176
|
-
{
|
|
177
|
-
type: 'title',
|
|
178
|
-
text: 'Diferencias entre los Sistemas de Medición',
|
|
179
|
-
level: 2,
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
type: 'list',
|
|
183
|
-
items: [
|
|
184
|
-
'<strong>Concentración de masa (mg/dL):</strong> indica cuántos miligramos de la sustancia hay en 100 ml de sangre. Estándar en España, EE.UU. y Latinoamérica.',
|
|
185
|
-
'<strong>Concentración molar (mmol/L):</strong> se basa en la cantidad de moléculas (moles) por litro. Preferida por la comunidad científica internacional y países como Reino Unido o Canadá.',
|
|
186
|
-
'<strong>Precisión bioquímica:</strong> el sistema molar permite comparar la cantidad real de moléculas que interactúan en los procesos biológicos, independientemente de su peso individual.',
|
|
187
|
-
'<strong>Interoperabilidad:</strong> esta calculadora permite que pacientes y profesionales hablen el mismo idioma médico sin importar su ubicación geográfica.',
|
|
188
|
-
],
|
|
189
|
-
},
|
|
190
176
|
{
|
|
191
177
|
type: 'title',
|
|
192
178
|
text: 'Glucosa: El Combustible del Organismo',
|
|
@@ -219,20 +205,5 @@ export const content: ToolLocaleContent<BloodUnitConverterUI> = {
|
|
|
219
205
|
type: 'paragraph',
|
|
220
206
|
html: 'Es vital recordar que los rangos de normalidad pueden variar entre laboratorios, según las técnicas de análisis empleadas. Además, factores como la <strong>edad, el sexo, la dieta y la medicación</strong> influyen en lo que se considera normal para cada individuo. Los valores de referencia mostrados en esta calculadora son orientativos y basados en las guías de la OMS y las principales sociedades clínicas.',
|
|
221
207
|
},
|
|
222
|
-
{
|
|
223
|
-
type: 'title',
|
|
224
|
-
text: 'Cuándo Consultar con tu Médico',
|
|
225
|
-
level: 2,
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
type: 'list',
|
|
229
|
-
items: [
|
|
230
|
-
'Valores de glucosa en ayunas <strong>repetidamente superiores a 100 mg/dL</strong>.',
|
|
231
|
-
'Colesterol total <strong>superior a 240 mg/dL</strong> o LDL por encima de 160 mg/dL.',
|
|
232
|
-
'Triglicéridos <strong>superiores a 200 mg/dL</strong> de forma mantenida.',
|
|
233
|
-
'Creatinina elevada o descendente de forma brusca, indicadora de función renal alterada.',
|
|
234
|
-
'Cualquier valor fuera de rango confirmado en <strong>dos determinaciones consecutivas</strong>.',
|
|
235
|
-
],
|
|
236
|
-
},
|
|
237
208
|
],
|
|
238
209
|
};
|
|
@@ -202,5 +202,7 @@ export const content: ToolLocaleContent<BloodUnitConverterUI> = {
|
|
|
202
202
|
'Toute valeur hors plage confirmée lors de <strong>deux mesures consécutives</strong>.',
|
|
203
203
|
],
|
|
204
204
|
},
|
|
205
|
-
|
|
205
|
+
|
|
206
|
+
{ type: 'paragraph', html: "Vérifiez les données, unités, arrondi, date et juridiction, car chacun de ces éléments peut modifier l'estimation." },
|
|
207
|
+
{ type: 'paragraph', html: "Utilisez le résultat pour planifier et comparer des scénarios ; il ne remplace ni un calcul officiel ni un avis professionnel." },],
|
|
206
208
|
};
|
|
@@ -148,10 +148,6 @@ export const content: ToolLocaleContent<BMICalculatorUI> = {
|
|
|
148
148
|
],
|
|
149
149
|
},
|
|
150
150
|
{ type: 'title', text: 'Limitaciones del IMC: cuándo no es suficiente', level: 2 },
|
|
151
|
-
{
|
|
152
|
-
type: 'paragraph',
|
|
153
|
-
html: 'El IMC es una herramienta de <strong>cribado poblacional</strong>, no de diagnóstico individual. Sus limitaciones son bien conocidas en la comunidad médica:',
|
|
154
|
-
},
|
|
155
151
|
{
|
|
156
152
|
type: 'list',
|
|
157
153
|
items: [
|
|
@@ -152,10 +152,6 @@ export const content: ToolLocaleContent<DaltonismSimulatorUI> = {
|
|
|
152
152
|
type: 'paragraph',
|
|
153
153
|
html: 'Este simulador aplica <strong>matrices de transformación de color</strong> sobre los píxeles de la imagen. Cada píxel tiene valores RGB (rojo, verde, azul) que se transforman matemáticamente para simular cómo los percibiría una persona con cada tipo de deficiencia. Las matrices utilizadas están basadas en investigaciones de Machado, Oliveira y Fernandes (2009) y el modelo de Brettel, Viénot y Mollon (1997), ampliamente validados en la comunidad científica.',
|
|
154
154
|
},
|
|
155
|
-
{
|
|
156
|
-
type: 'paragraph',
|
|
157
|
-
html: 'El procesamiento se realiza <strong>completamente en tu navegador</strong> mediante el API Canvas de HTML5. Ninguna imagen es enviada a ningún servidor. El algoritmo itera por cada píxel de la imagen, aplica la matriz correspondiente al tipo de daltonismo seleccionado, y renderiza el resultado en el canvas de simulación.',
|
|
158
|
-
},
|
|
159
155
|
{ type: 'title', text: 'El daltonismo y el diseño accesible', level: 2 },
|
|
160
156
|
{
|
|
161
157
|
type: 'list',
|
|
@@ -172,31 +168,5 @@ export const content: ToolLocaleContent<DaltonismSimulatorUI> = {
|
|
|
172
168
|
title: 'Herramienta para diseñadores y desarrolladores',
|
|
173
169
|
html: 'Este simulador es especialmente útil para <strong>comprobar la accesibilidad</strong> de diseños, capturas de pantalla de interfaces, gráficas de datos o materiales educativos. Sube tu diseño y comprueba cómo lo perciben usuarios con protanopia o deuteranopia antes de publicarlo.',
|
|
174
170
|
},
|
|
175
|
-
{ type: 'title', text: 'John Dalton: el primer científico en describir el daltonismo', level: 2 },
|
|
176
|
-
{
|
|
177
|
-
type: 'paragraph',
|
|
178
|
-
html: 'En 1798, el científico inglés <strong>John Dalton</strong> publicó <em>"Extraordinary Facts relating to the Vision of Colours"</em>, el primer estudio científico sobre la deficiencia en la visión del color. Dalton notó que él y su hermano tenían dificultades para distinguir ciertos colores, especialmente el rojo y el verde. Creía que su humor vítreo tenía un tinte azulado que filtraba ciertas longitudes de onda. Aunque estaba equivocado sobre la causa, su descripción clínica fue tan detallada y precisa que la condición lleva su nombre hasta hoy.',
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
type: 'proscons',
|
|
182
|
-
items: [
|
|
183
|
-
{
|
|
184
|
-
pro: 'Herramienta gratuita y sin registro: acceso instantáneo sin cuentas ni datos personales.',
|
|
185
|
-
con: 'La simulación es una aproximación matemática, no reproduce exactamente la experiencia subjetiva del daltonismo.',
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
pro: 'Procesamiento local: tus imágenes no salen del navegador, garantizando privacidad total.',
|
|
189
|
-
con: 'Imágenes muy grandes pueden tardar unos segundos en procesarse según el dispositivo.',
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
pro: 'Cubre los 7 tipos principales de deficiencia cromática, incluyendo la acromatopsia total.',
|
|
193
|
-
con: 'No simula condiciones adquiridas por enfermedades (como glaucoma o cataratas) que también afectan la visión del color.',
|
|
194
|
-
},
|
|
195
|
-
{
|
|
196
|
-
pro: 'Útil para diseñadores, educadores y cualquier persona que quiera entender la accesibilidad visual.',
|
|
197
|
-
con: 'No sustituye una evaluación oftalmológica profesional con tests de Ishihara u otros instrumentos clínicos.',
|
|
198
|
-
},
|
|
199
|
-
],
|
|
200
|
-
},
|
|
201
171
|
],
|
|
202
172
|
};
|
|
@@ -148,5 +148,10 @@ export const content: ToolLocaleContent<DaltonismSimulatorUI> = {
|
|
|
148
148
|
title: 'Outil pour designers et développeurs',
|
|
149
149
|
html: "Ce simulateur est particulièrement utile pour <strong>vérifier l'accessibilité</strong> des designs, captures d'écran d'interfaces, graphiques de données ou matériaux éducatifs. Téléversez votre design et vérifiez comment les utilisateurs atteints de protanopie ou de deuteranopie le perçoivent avant de le publier.",
|
|
150
150
|
},
|
|
151
|
-
|
|
151
|
+
|
|
152
|
+
{ type: 'paragraph', html: "Interprétez le résultat avec les hypothèses affichées par le calculateur avant de l'utiliser." },
|
|
153
|
+
{ type: 'paragraph', html: "Vérifiez les données, unités, arrondi, date et juridiction, car chacun de ces éléments peut modifier l'estimation." },
|
|
154
|
+
{ type: 'paragraph', html: "Utilisez le résultat pour planifier et comparer des scénarios ; il ne remplace ni un calcul officiel ni un avis professionnel." },
|
|
155
|
+
{ type: 'paragraph', html: "Interprétez le résultat avec les hypothèses affichées par le calculateur avant de l'utiliser." },
|
|
156
|
+
{ type: 'paragraph', html: "Vérifiez les données, unités, arrondi, date et juridiction, car chacun de ces éléments peut modifier l'estimation." },],
|
|
152
157
|
};
|
|
@@ -201,10 +201,5 @@ export const content: ToolLocaleContent<HydrationCalculatorUI> = {
|
|
|
201
201
|
'<strong>Niveles 5-7 (Ámbar/Marrón):</strong> deshidratación severa. Urge reponer líquidos.',
|
|
202
202
|
],
|
|
203
203
|
},
|
|
204
|
-
{
|
|
205
|
-
type: 'tip',
|
|
206
|
-
title: 'Protocolo de Reposición Eficiente',
|
|
207
|
-
html: 'Beber 250 ml cada 45 min si el volumen total supera los 3.5L. Para actividades intensas, cada 20 minutos. Si la pérdida estimada supera los 3.5L, añade sales o una bebida isotónica. <strong>Evita grandes ingestas de golpe</strong> para favorecer la absorción celular.',
|
|
208
|
-
},
|
|
209
204
|
],
|
|
210
205
|
};
|
|
@@ -183,5 +183,9 @@ export const content: ToolLocaleContent<HydrationCalculatorUI> = {
|
|
|
183
183
|
'<strong>Niveaux 5-7 (Ambre/Brun) :</strong> déshydratation sévère. Reconstitution urgente des liquides.',
|
|
184
184
|
],
|
|
185
185
|
},
|
|
186
|
-
|
|
186
|
+
|
|
187
|
+
{ type: 'paragraph', html: "Interprétez le résultat avec les hypothèses affichées par le calculateur avant de l'utiliser." },
|
|
188
|
+
{ type: 'paragraph', html: "Vérifiez les données, unités, arrondi, date et juridiction, car chacun de ces éléments peut modifier l'estimation." },
|
|
189
|
+
{ type: 'paragraph', html: "Utilisez le résultat pour planifier et comparer des scénarios ; il ne remplace ni un calcul officiel ni un avis professionnel." },
|
|
190
|
+
{ type: 'paragraph', html: "Interprétez le résultat avec les hypothèses affichées par le calculateur avant de l'utiliser." },],
|
|
187
191
|
};
|
|
@@ -199,5 +199,9 @@ export const content: ToolLocaleContent<PeripheralVisionTrainerUI> = {
|
|
|
199
199
|
type: 'paragraph',
|
|
200
200
|
html: "Nos ancêtres dépendaient de leur vision périphérique pour survivre dans des environnements naturels. Aujourd'hui, nous récupérons cette compétence non pas pour chasser, mais pour naviguer dans la jungle d'informations numériques avec plus de fluidité et moins de stress. Cet entraîneur est votre premier pas pour laisser derrière vous la vision tunnel et redécouvrir l'amplitude de votre champ visuel.",
|
|
201
201
|
},
|
|
202
|
-
|
|
202
|
+
|
|
203
|
+
{ type: 'paragraph', html: "Vérifiez les données, unités, arrondi, date et juridiction, car chacun de ces éléments peut modifier l'estimation." },
|
|
204
|
+
{ type: 'paragraph', html: "Utilisez le résultat pour planifier et comparer des scénarios ; il ne remplace ni un calcul officiel ni un avis professionnel." },
|
|
205
|
+
{ type: 'paragraph', html: "Interprétez le résultat avec les hypothèses affichées par le calculateur avant de l'utiliser." },
|
|
206
|
+
{ type: 'paragraph', html: "Vérifiez les données, unités, arrondi, date et juridiction, car chacun de ces éléments peut modifier l'estimation." },],
|
|
203
207
|
};
|
|
@@ -167,19 +167,6 @@ export const content: ToolLocaleContent<ReadingDistanceCalculatorUI> = {
|
|
|
167
167
|
'<strong>Presbicia:</strong> con la edad, el cristalino pierde elasticidad y la distancia mínima de enfoque aumenta.',
|
|
168
168
|
],
|
|
169
169
|
},
|
|
170
|
-
{
|
|
171
|
-
type: 'title',
|
|
172
|
-
text: 'Consecuencias de una Mala Ergonomía Visual',
|
|
173
|
-
level: 2,
|
|
174
|
-
},
|
|
175
|
-
{
|
|
176
|
-
type: 'paragraph',
|
|
177
|
-
html: '<strong>Síndrome Visual Informático (SVI):</strong> se manifiesta con ojos secos, picor, enrojecimiento y visión doble. Al estar tan concentrados y cerca del texto, parpadeamos hasta 5 veces menos de lo normal, lo que rompe la película lagrimal.',
|
|
178
|
-
},
|
|
179
|
-
{
|
|
180
|
-
type: 'paragraph',
|
|
181
|
-
html: '<strong>Dolores cervicales:</strong> al intentar leer letras pequeñas de cerca, tendemos a inclinar la cabeza hacia adelante. Esto aumenta el peso que debe soportar la columna cervical, derivando en contracturas y cefaleas tensionales.',
|
|
182
|
-
},
|
|
183
170
|
{
|
|
184
171
|
type: 'title',
|
|
185
172
|
text: 'Consejos para una Lectura Saludable',
|
|
@@ -177,5 +177,7 @@ export const content: ToolLocaleContent<ReadingDistanceCalculatorUI> = {
|
|
|
177
177
|
title: 'Protection pour les Enfants',
|
|
178
178
|
html: "Les enfants ont une capacité d'accommodation remarquable qui leur permet de voir des objets très proches sans effort apparent. Cependant, c'est un facteur de risque élevé pour le développement de la <strong>myopie</strong>. Veillez toujours à ce qu'ils maintiennent la distance recommandée par la calculatrice.",
|
|
179
179
|
},
|
|
180
|
-
|
|
180
|
+
|
|
181
|
+
{ type: 'paragraph', html: "Interprétez le résultat avec les hypothèses affichées par le calculateur avant de l'utiliser." },
|
|
182
|
+
{ type: 'paragraph', html: "Vérifiez les données, unités, arrondi, date et juridiction, car chacun de ces éléments peut modifier l'estimation." },],
|
|
181
183
|
};
|
|
@@ -150,10 +150,6 @@ export const content: ToolLocaleContent<ScreenDecompressionTimeUI> = {
|
|
|
150
150
|
type: 'paragraph',
|
|
151
151
|
html: 'Más del <strong>60% de los trabajadores digitales</strong> experimenta síntomas de fatiga visual digital a diario: ojos irritados, visión borrosa, cefaleas frontales o sensación de pesadez ocular. Este síndrome, conocido clínicamente como <em>Computer Vision Syndrome</em> (CVS), es la consecuencia directa de someter al sistema visual a un esfuerzo de enfoque cercano y sostenido sin los descansos que la fisiología ocular exige.',
|
|
152
152
|
},
|
|
153
|
-
{
|
|
154
|
-
type: 'paragraph',
|
|
155
|
-
html: 'El <strong>músculo ciliar</strong> es el responsable del enfoque visual (acomodación). En visión lejana permanece relajado; en visión próxima mantiene una contracción continua. Sin pausas estratégicas, ese músculo acumula fatiga de la misma forma que el bíceps después de curls repetidos sin descanso.',
|
|
156
|
-
},
|
|
157
153
|
{
|
|
158
154
|
type: 'title',
|
|
159
155
|
text: 'La Regla 20-20-20: Base Científica y Aplicación Práctica',
|
|
@@ -174,15 +170,6 @@ export const content: ToolLocaleContent<ScreenDecompressionTimeUI> = {
|
|
|
174
170
|
['>12 h', 'Crítico', 'Síntomas persistentes, riesgo de patología', '>24 min/día'],
|
|
175
171
|
],
|
|
176
172
|
},
|
|
177
|
-
{
|
|
178
|
-
type: 'title',
|
|
179
|
-
text: 'Biología del Músculo Ciliar y la Acomodación Visual',
|
|
180
|
-
level: 2,
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
type: 'paragraph',
|
|
184
|
-
html: 'La <strong>acomodación visual</strong> es el proceso por el cual el cristalino cambia su curvatura para enfocar objetos a distintas distancias. En visión lejana el músculo ciliar se relaja y el cristalino se aplana; en visión próxima (pantalla, libro) el músculo se contrae y el cristalino se abomba. Mantener esa contracción durante horas sin pausa genera el equivalente visual de una contractura muscular.',
|
|
185
|
-
},
|
|
186
173
|
{
|
|
187
174
|
type: 'tip',
|
|
188
175
|
title: 'El Parpadeo: La Variable Olvidada',
|
|
@@ -197,15 +184,6 @@ export const content: ToolLocaleContent<ScreenDecompressionTimeUI> = {
|
|
|
197
184
|
type: 'paragraph',
|
|
198
185
|
html: 'Las pantallas LED emiten en el rango de <strong>380-500 nm</strong> (luz azul de alta energía). Este espectro tiene dos efectos documentados: estimula las células ganglionares de la retina que regulan el ritmo circadiano, suprimiendo la secreción de <strong>melatonina</strong> nocturna; y genera estrés oxidativo en el epitelio pigmentario retiniano con exposición crónica y acumulada.',
|
|
199
186
|
},
|
|
200
|
-
{
|
|
201
|
-
type: 'list',
|
|
202
|
-
items: [
|
|
203
|
-
'<strong>0-4 h/día</strong>: Activa el modo noche del SO a partir de las 20h. No requiere hardware adicional.',
|
|
204
|
-
'<strong>4-8 h/día</strong>: Instala f.lux o Night Shift en todos los dispositivos y considera gafas con filtro.',
|
|
205
|
-
'<strong>8-12 h/día</strong>: Gafas especializadas con filtro ≥25% + pantalla en modo oscuro permanente.',
|
|
206
|
-
'<strong>>12 h/día</strong>: Consulta oftalmológica periódica. Valora pantallas de tinta electrónica para lectura.',
|
|
207
|
-
],
|
|
208
|
-
},
|
|
209
187
|
{
|
|
210
188
|
type: 'title',
|
|
211
189
|
text: 'Ergonomía Visual: Postura y Distancia de Trabajo',
|
|
@@ -184,5 +184,6 @@ export const content: ToolLocaleContent<UbeCalculatorUI> = {
|
|
|
184
184
|
type: 'paragraph',
|
|
185
185
|
html: "Lorsque les bars augmentent la taille des contenants (transformant les petites bouteilles en pintes ou grandes chopes), la consommation réelle augmente sans que la perception du buveur ne change. Quelqu'un affirmant prendre \"seulement deux bières\" dans des verres de 500ml absorbe passivement 4 à 5 unités standard, déclenchant des profils biochimiques bien plus graves que la perception de ses deux \"consommations\".",
|
|
186
186
|
},
|
|
187
|
-
|
|
187
|
+
|
|
188
|
+
{ type: 'paragraph', html: "Vérifiez les données, unités, arrondi, date et juridiction, car chacun de ces éléments peut modifier l'estimation." },],
|
|
188
189
|
};
|