1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
|
export type Language = 'en' | 'da';
export const languageOptions: { id: Language; label: string }[] = [
{ id: 'en', label: 'English' },
{ id: 'da', label: 'Dansk' },
];
export interface Chrome {
subtitle: string;
title: string;
documentTitle: string;
metaDescription: string;
home: string;
collection: string;
language: string;
test: string;
experiments: string;
overview: string;
overviewTitle: string;
overviewText: string;
score: string;
subjects: string;
lessons: string;
reset: string;
original: string;
parameters: string;
measurements: string;
camera: string;
pause: string;
resume: string;
hint: string;
notice: string;
connect: string;
physics: string;
learn: string;
try: string;
scales: string;
build: string;
reasoning: string;
originalAnswers: string;
predict: string;
reveal: string;
hide: string;
navigation: string;
previous: string;
next: string;
view: string;
viewport: string;
study: string;
canvas: string;
webglStart: string;
webglLost: string;
experimentKicker: (index: number, total: number) => string;
questionKicker: (section: string, question: number, points: number) => string;
}
const pad = (value: number) => String(value).padStart(2, '0');
const chrome: Record<Language, Chrome> = {
en: {
subtitle: 'Explore the invisible. Understand the physical.',
title: 'Electrotechnique & Electrophysics',
documentTitle: 'Test 4 in 3D · Electrophysics Lab',
metaDescription: 'Learn electric work, potential energy and dipole rotation through six interactive 3D lessons from Test 4.',
home: 'Field Lab home',
collection: 'Learning collection',
language: 'Language',
test: 'Test 4 in 3D',
experiments: 'All experiments',
overview: 'FROM EQUATIONS TO INTUITION',
overviewTitle: 'Electric energy, made visible.',
overviewText: 'Explore the six questions from Test 4. Change the scene, make a prediction, then follow the derivation.',
score: '6 questions · 3D study guide',
subjects: 'Electrophysics subjects',
lessons: 'Lessons',
reset: 'Reset experiment',
original: 'Original test question',
parameters: 'Experiment parameters',
measurements: 'Live measurements',
camera: 'Reset camera',
pause: 'Pause animation',
resume: 'Resume animation',
hint: 'Drag to orbit · Scroll to zoom · Right-drag to pan · Arrow keys to pan when focused',
notice: 'NOTICE WHAT CHANGES',
connect: 'Connect the scene to the answer',
physics: 'THE PHYSICS',
learn: 'Learn the physics',
try: 'TRY THIS',
scales: 'Model & visual scales',
build: 'BUILD YOUR ANSWER',
reasoning: 'Follow the reasoning',
originalAnswers: 'Original answers ↗',
predict: 'PAUSE & PREDICT',
reveal: 'Reveal explanation',
hide: 'Hide explanation',
navigation: 'Question navigation',
previous: '← Previous question',
next: 'Next question →',
view: '3D VIEW',
viewport: '3D workbench',
study: 'Test answers and prediction',
canvas: '3D experiment: drag to orbit, scroll to zoom, right-drag to pan',
webglStart: 'WebGL could not start. Enable browser hardware acceleration and reload to see the 3D scenes. Lessons, controls and measurements still work.',
webglLost: 'WebGL context lost. Reload to restore the 3D view. Numeric experiments still work.',
experimentKicker: (index, total) => `EXPERIMENT ${pad(index)} / ${pad(total)}`,
questionKicker: (section, question, points) => `TEST ${section} · QUESTION ${question} · ${points} POINTS`,
},
da: {
subtitle: 'Udforsk det usynlige. Forstå fysikken.',
title: 'Elektroteknik og elektrofysik',
documentTitle: 'Test 4 i 3D · Elektrofysiklaboratoriet',
metaDescription: 'Lær elektrisk arbejde, potentiel energi og dipolrotation gennem seks interaktive 3D-lektioner fra Test 4.',
home: 'Feltlaboratoriets forside',
collection: 'Læringssamling',
language: 'Sprog',
test: 'Test 4 i 3D',
experiments: 'Alle eksperimenter',
overview: 'FRA LIGNINGER TIL INTUITION',
overviewTitle: 'Elektrisk energi gjort synlig.',
overviewText: 'Udforsk de seks spørgsmål fra Test 4. Ændr scenen, forudsig resultatet, og følg derefter udledningen.',
score: '6 spørgsmål · 3D-studievejledning',
subjects: 'Elektrofysikkens emner',
lessons: 'Lektioner',
reset: 'Nulstil eksperiment',
original: 'Oprindeligt eksamensspørgsmål',
parameters: 'Eksperimentparametre',
measurements: 'Målinger i realtid',
camera: 'Nulstil kamera',
pause: 'Sæt animation på pause',
resume: 'Genoptag animation',
hint: 'Træk for at dreje · Rul for at zoome · Højreklik og træk for at panorere · Piletaster panorerer, når feltet er valgt',
notice: 'BEMÆRK, HVAD DER ÆNDRER SIG',
connect: 'Forbind scenen med svaret',
physics: 'FYSIKKEN',
learn: 'Lær fysikken',
try: 'PRØV DETTE',
scales: 'Model- og visuelle skalaer',
build: 'BYG DIT SVAR',
reasoning: 'Følg ræsonnementet',
originalAnswers: 'Oprindelige svar ↗',
predict: 'PAUSE OG FORUDSIG',
reveal: 'Vis forklaring',
hide: 'Skjul forklaring',
navigation: 'Spørgsmålsnavigation',
previous: '← Forrige spørgsmål',
next: 'Næste spørgsmål →',
view: '3D-VISNING',
viewport: '3D-arbejdsbord',
study: 'Prøvesvar og forudsigelse',
canvas: '3D-eksperiment: træk for at dreje, rul for at zoome, højreklik og træk for at panorere',
webglStart: 'WebGL kunne ikke starte. Slå browserens hardwareacceleration til, og genindlæs for at se 3D-scenerne. Lektioner, betjening og målinger virker stadig.',
webglLost: 'WebGL-konteksten gik tabt. Genindlæs for at gendanne 3D-visningen. De numeriske eksperimenter virker stadig.',
experimentKicker: (index, total) => `EKSPERIMENT ${pad(index)} / ${pad(total)}`,
questionKicker: (section, question, points) => `TEST ${section} · SPØRGSMÅL ${question} · ${points} POINT`,
},
};
const scenePhrases = {
en: {
origin: 'Origin',
onRightCharge: 'on right charge',
gaussian: 'Gaussian surface',
probe: 'Probe',
isolated: 'isolated',
connected: 'supply connected',
noCurrent: 'No net current',
conventional: 'Conventional current',
drift: String.raw`Electron drift · displayed $\times600$`,
conduction: 'Conduction band',
valence: 'Valence band',
midgap: 'midgap',
gap: 'gap',
energyConstant: 'Energy changes form · total stays constant',
},
da: {
origin: 'Origo',
onRightCharge: 'på højre ladning',
gaussian: 'Gaussisk flade',
probe: 'Sonde',
isolated: 'isoleret',
connected: 'forsyning tilsluttet',
noCurrent: 'Ingen nettostrøm',
conventional: 'Konventionel strøm',
drift: String.raw`Elektrondrift · vist $\times600$`,
conduction: 'Ledningsbånd',
valence: 'Valensbånd',
midgap: 'båndmidte',
gap: 'gab',
energyConstant: 'Energien skifter form · summen er konstant',
},
} as const;
export type SceneKey = keyof typeof scenePhrases.en;
const words: Record<Language, Record<string, string>> = {
en: {},
da: {
Repulsive: 'Frastødende',
Attractive: 'Tiltrækkende',
'On surface': 'På overfladen',
Inside: 'Inden for',
Outside: 'Uden for',
Undefined: 'Udefineret',
pairs: 'par',
},
};
const measurementLabels: Record<Language, Record<string, string>> = {
en: {},
da: {
'|E| at origin': '|E| i origo',
'|Force on right charge|': '|Kraft på højre ladning|',
Interaction: 'Vekselvirkning',
'Net electric flux': 'Netto elektrisk flux',
'Enclosed charge': 'Indesluttet ladning',
'Source location': 'Kildens placering',
'Interaction energy': 'Vekselvirkningsenergi',
'Work by field, from 6 m': 'Feltets arbejde fra 6 m',
'|Force on moving charge|': '|Kraft på den bevægelige ladning|',
'Potential at probe': 'Potential ved sonden',
'Ex at probe': 'Ex ved sonden',
'|E| at probe': '|E| ved sonden',
Capacitance: 'Kapacitans',
'Stored free charge |Q|': 'Lagret fri ladning |Q|',
'Electric field': 'Elektrisk felt',
'Stored energy': 'Lagret energi',
'Plate voltage': 'Pladespænding',
'Free charge |Q|': 'Fri ladning |Q|',
'Net field': 'Nettofelt',
'Polarization |P|': 'Polarisering |P|',
Resistance: 'Resistans',
'Conventional current': 'Konventionel strøm',
'Electron drift, x': 'Elektrondrift, x',
'Dissipated power': 'Afgivet effekt',
'Electron density n': 'Elektrontæthed n',
'Hole density p': 'Hulletæthed p',
'Conductivity σ': 'Ledningsevne σ',
'Fermi shift EF − Ei': 'Forskydning af Fermi EF − Ei',
'Field work a → b': 'Feltets arbejde a → b',
'Work to current position': 'Arbejde til nuværende position',
'Potential energy Uᵦ': 'Potentiel energi Uᵦ',
'Potential energy U': 'Potentiel energi U',
'Kinetic energy K': 'Kinetisk energi K',
'Total K + U': 'Samlet K + U',
'Particle speed': 'Partikelhastighed',
'Potential energy Uₐ': 'Potentiel energi Uₐ',
'Energy of current assembly': 'Energi af den aktuelle samling',
'Three-charge energy U₁₂₃': 'Tre ladningers energi U₁₂₃',
'Additional energy ΔU₀': 'Ekstra energi ΔU₀',
'Pairs currently assembled': 'Par samlet nu',
'Projection Δx': 'Projektion Δx',
'Current angle θ': 'Aktuel vinkel θ',
'Torque τz': 'Kraftmoment τz',
'Work to current angle': 'Arbejde til aktuel vinkel',
},
};
function storedLanguage(): Language {
try {
const stored = globalThis.localStorage?.getItem('language');
if (stored === 'da' || stored === 'en') return stored;
} catch {
// Storage can be unavailable in private or non-browser contexts.
}
return 'en';
}
let language: Language = storedLanguage();
const listeners = new Set<() => void>();
export function getLanguage(): Language {
return language;
}
export function setLanguage(next: Language) {
language = next;
try {
localStorage.setItem('language', next);
} catch {
// Ignore storage failures; the in-memory choice still applies.
}
if (typeof document !== 'undefined') document.documentElement.lang = next;
for (const listener of listeners) listener();
}
export function onLanguageChange(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function t<K extends keyof Chrome>(key: K): Chrome[K] {
return chrome[language][key];
}
export function scene(key: SceneKey): string {
return scenePhrases[language][key];
}
export function word(english: string): string {
return words[language][english] ?? english;
}
export function measurementLabel(english: string): string {
return measurementLabels[language][english] ?? english;
}
export const daMeasurementLabels = measurementLabels.da;
export const daWords = words.da;
|