MikaFil commited on
Commit
6fa5b91
·
verified ·
1 Parent(s): fd52f1d

Create tooltips.js

Browse files
Files changed (1) hide show
  1. tooltips.js +395 -0
tooltips.js ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // tooltips.js
2
+ //
3
+ // - Charge le JSON depuis GitHub Raw (par défaut) en utilisant un GET conditionnel (If-None-Match),
4
+ // ce qui évite les problèmes Safari/iOS liés aux requêtes HEAD et maximise la compatibilité CORS.
5
+ // - Met en cache la dernière version valide (localStorage si possible, sinon repli mémoire).
6
+ // - En cas d'erreur réseau/parse, retombe sur la dernière copie valide, sinon tente un GET avec cache-buster.
7
+ // - Supporte camX/camY/camZ pour placer la caméra exactement à ces coordonnées et regarder le tooltip.
8
+ //
9
+ // Utilisation minimale :
10
+ // initializeTooltips({ app, cameraEntity, defaultVisible: true })
11
+ //
12
+ // Options utiles :
13
+ // - tooltipsUrl : pour surcharger l'URL (défaut : DEFAULT_TOOLTIPS_URL)
14
+ // - cacheMode : 'default' | 'no-cache' | 'reload' | 'no-store' (défaut : 'no-cache' afin de revalider proprement)
15
+ // - moveDuration: durée (s) de l’animation caméra
16
+
17
+ const DEFAULT_TOOLTIPS_URL =
18
+ "https://raw.githubusercontent.com/mika-fi/sgos_dataset/main/exemples/baleine/tooltips.json";
19
+
20
+ // --- Stockage sûr (localStorage protégé + repli mémoire pour iOS privé / ITP / iframes) ---
21
+ const __memStore = Object.create(null);
22
+ function safeGetItem(key) {
23
+ try { return localStorage.getItem(key); } catch (_) { return __memStore[key] ?? null; }
24
+ }
25
+ function safeSetItem(key, value) {
26
+ try { localStorage.setItem(key, value); } catch (_) { __memStore[key] = value; }
27
+ }
28
+ function safeRemoveItem(key) {
29
+ try { localStorage.removeItem(key); } catch (_) { delete __memStore[key]; }
30
+ }
31
+
32
+ // --- Parse JSON sûr (évite de crasher le flux en cas de contenu invalide) ---
33
+ async function safeParseJson(resp) {
34
+ const text = await resp.text();
35
+ try {
36
+ return JSON.parse(text);
37
+ } catch (e) {
38
+ // JSON invalide (rare sur GitHub Raw, mais mieux vaut prévenir)
39
+ throw new Error("Invalid JSON");
40
+ }
41
+ }
42
+
43
+ /**
44
+ * fetchWithETag(url, { cacheMode, bustOnError })
45
+ * - GET conditionnel avec If-None-Match pour obtenir 304 si pas de changement
46
+ * - Conserve ETag + data localement
47
+ * - Fallback : sert la dernière copie valide, sinon GET "no-store" avec cache-buster
48
+ * - Évite les HEAD (souvent source d'ennuis avec Safari/CORS/redirections)
49
+ */
50
+ async function fetchWithETag(url, { cacheMode = "no-cache", bustOnError = true } = {}) {
51
+ const LS_KEY_DATA = `tooltips:data:${url}`;
52
+ const LS_KEY_ETAG = `tooltips:etag:${url}`;
53
+ const prevEtag = safeGetItem(LS_KEY_ETAG);
54
+ const prevDataStr = safeGetItem(LS_KEY_DATA);
55
+ const prevData = prevDataStr ? (() => { try { return JSON.parse(prevDataStr); } catch { return null; } })() : null;
56
+
57
+ try {
58
+ // 1) GET conditionnel (If-None-Match) pour maximiser la compat safari/ios et CDN
59
+ const headers = {};
60
+ if (prevEtag) headers["If-None-Match"] = prevEtag;
61
+
62
+ const resp = await fetch(url, {
63
+ method: "GET",
64
+ cache: cacheMode, // 'no-cache' pour revalidation, 'no-store' pour refetch strict
65
+ mode: "cors",
66
+ redirect: "follow",
67
+ credentials: "omit",
68
+ headers
69
+ });
70
+
71
+ // 304 = pas de changement -> réutilise la dernière copie locale
72
+ if (resp.status === 304 && prevData) {
73
+ return prevData;
74
+ }
75
+
76
+ if (!resp.ok) {
77
+ if (prevData) return prevData;
78
+ throw new Error(`HTTP ${resp.status}`);
79
+ }
80
+
81
+ // 2) Nouvelle version téléchargée
82
+ const json = await safeParseJson(resp);
83
+
84
+ // 3) Enregistre ETag + data
85
+ const newEtag = resp.headers.get("ETag") || resp.headers.get("etag") || null;
86
+ if (newEtag) safeSetItem(LS_KEY_ETAG, newEtag);
87
+ safeSetItem(LS_KEY_DATA, JSON.stringify(json));
88
+
89
+ return json;
90
+ } catch (err) {
91
+ // 4) Fallback : si échec réseau/parse, renvoie la dernière copie locale si dispo
92
+ if (prevData) return prevData;
93
+
94
+ // 5) Ultime recours : cache-buster no-store (utile contre caches "têtus" de Safari/iOS)
95
+ if (bustOnError) {
96
+ const sep = url.includes("?") ? "&" : "?";
97
+ const bustedUrl = `${url}${sep}t=${Date.now()}`;
98
+ try {
99
+ const bustResp = await fetch(bustedUrl, {
100
+ method: "GET",
101
+ cache: "no-store",
102
+ mode: "cors",
103
+ redirect: "follow",
104
+ credentials: "omit"
105
+ });
106
+ if (!bustResp.ok) throw new Error(`HTTP ${bustResp.status}`);
107
+ const fresh = await safeParseJson(bustResp);
108
+ // sauvegarde sans ETag (inconnu sur la requête bustée)
109
+ safeSetItem(LS_KEY_DATA, JSON.stringify(fresh));
110
+ return fresh;
111
+ } catch (e2) {
112
+ throw err; // On remonte l'erreur d'origine si tout échoue
113
+ }
114
+ }
115
+
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * initializeTooltips(options)
122
+ *
123
+ * - options.app: the PlayCanvas AppBase instance
124
+ * - options.cameraEntity: PlayCanvas camera Entity (utilisant le script orbitCamera)
125
+ * - options.modelEntity: the main model entity (for relative positioning, optional)
126
+ * - options.tooltipsUrl: URL du JSON (optionnel, défaut = DEFAULT_TOOLTIPS_URL)
127
+ * - options.defaultVisible: booléen : tooltips visibles au démarrage
128
+ * - options.moveDuration: durée (s) de l’animation caméra (défaut 0.6)
129
+ * - options.cacheMode: 'default' | 'no-cache' | 'reload' | 'no-store' (défaut 'no-cache' → revalidation correcte)
130
+ */
131
+ export async function initializeTooltips(options) {
132
+ const {
133
+ app,
134
+ cameraEntity,
135
+ modelEntity, // conservé pour compat
136
+ tooltipsUrl = DEFAULT_TOOLTIPS_URL,
137
+ defaultVisible,
138
+ moveDuration = 0.6,
139
+ cacheMode = "no-cache"
140
+ } = options || {};
141
+
142
+ if (!app || !cameraEntity || !tooltipsUrl) return;
143
+
144
+ // --- Chargement robuste des tooltips (dernière version + fallback) ---
145
+ let tooltipsData;
146
+ try {
147
+ tooltipsData = await fetchWithETag(tooltipsUrl, { cacheMode, bustOnError: true });
148
+ } catch (e) {
149
+ // Aucune donnée exploitable -> on abandonne proprement
150
+ return;
151
+ }
152
+ if (!Array.isArray(tooltipsData)) return;
153
+
154
+ const tooltipEntities = [];
155
+
156
+ // --- Matériau des sphères (tooltips) ---
157
+ const mat = new pc.StandardMaterial();
158
+ mat.diffuse = new pc.Color(1, 0.8, 0);
159
+ mat.specular = new pc.Color(1, 1, 1);
160
+ mat.shininess = 20;
161
+ mat.emissive = new pc.Color(0.85, 0.85, 0.85);
162
+ mat.emissiveIntensity = 1;
163
+ mat.useLighting = false;
164
+ mat.update();
165
+
166
+ // --- Création des entités sphères pour chaque tooltip ---
167
+ for (let i = 0; i < tooltipsData.length; i++) {
168
+ const tt = tooltipsData[i];
169
+ const { x, y, z, title, description, imgUrl, camX, camY, camZ } = tt;
170
+
171
+ const sphere = new pc.Entity("tooltip-" + i);
172
+ sphere.addComponent("model", { type: "sphere" });
173
+ sphere.model.material = mat;
174
+
175
+ // Taille par défaut des "pins"
176
+ sphere.setLocalScale(0.05, 0.05, 0.05);
177
+ sphere.setLocalPosition(x, y, z);
178
+
179
+ // Infos du tooltip (UI + cible caméra optionnelle)
180
+ sphere.tooltipData = {
181
+ title,
182
+ description,
183
+ imgUrl,
184
+ camTarget:
185
+ Number.isFinite(camX) && Number.isFinite(camY) && Number.isFinite(camZ)
186
+ ? new pc.Vec3(camX, camY, camZ)
187
+ : null
188
+ };
189
+
190
+ app.root.addChild(sphere);
191
+ tooltipEntities.push(sphere);
192
+ }
193
+
194
+ // --- Visibilité initiale + contrôle externe ---
195
+ function setTooltipsVisibility(visible) {
196
+ tooltipEntities.forEach(ent => { ent.enabled = visible; });
197
+ }
198
+ setTooltipsVisibility(!!defaultVisible);
199
+
200
+ // Écouteur externe (ex. UI HTML) pour afficher/masquer les tooltips
201
+ document.addEventListener("toggle-tooltips", (evt) => {
202
+ const { visible } = evt.detail || {};
203
+ setTooltipsVisibility(!!visible);
204
+ });
205
+
206
+ // --- Picking (détection de clic sur un tooltip) ---
207
+ let currentTween = null;
208
+
209
+ app.mouse.on(pc.EVENT_MOUSEDOWN, (event) => {
210
+ // Si une interpolation est en cours, on l'arrête proprement
211
+ if (currentTween) {
212
+ app.off("update", currentTween);
213
+ currentTween = null;
214
+ }
215
+
216
+ const x = event.x, y = event.y;
217
+ const from = new pc.Vec3(), to = new pc.Vec3();
218
+ const camera = cameraEntity.camera;
219
+
220
+ // Ray picking écran -> monde
221
+ camera.screenToWorld(x, y, camera.nearClip, from);
222
+ camera.screenToWorld(x, y, camera.farClip, to);
223
+
224
+ const dir = new pc.Vec3().sub2(to, from).normalize();
225
+
226
+ let closestT = Infinity;
227
+ let pickedEntity = null;
228
+
229
+ // Test d'intersection rayon/sphère (simple et suffisant ici)
230
+ for (const ent of tooltipEntities) {
231
+ if (!ent.enabled) continue;
232
+
233
+ const center = ent.getPosition();
234
+ const worldRadius = 0.5 * ent.getLocalScale().x;
235
+
236
+ const oc = new pc.Vec3().sub2(center, from);
237
+ const tca = oc.dot(dir);
238
+ if (tca < 0) continue;
239
+
240
+ const d2 = oc.lengthSq() - (tca * tca);
241
+ if (d2 > worldRadius * worldRadius) continue;
242
+
243
+ const thc = Math.sqrt(worldRadius * worldRadius - d2);
244
+ const t0 = tca - thc;
245
+ if (t0 < closestT && t0 >= 0) {
246
+ closestT = t0;
247
+ pickedEntity = ent;
248
+ }
249
+ }
250
+
251
+ if (pickedEntity) {
252
+ // Notifier l'UI (titre, description, image)
253
+ const { title, description, imgUrl } = pickedEntity.tooltipData;
254
+ document.dispatchEvent(new CustomEvent("tooltip-selected", {
255
+ detail: { title, description, imgUrl }
256
+ }));
257
+
258
+ // Si on a une position caméra cible, on l'utilise
259
+ const desiredCamPos = pickedEntity.tooltipData.camTarget;
260
+ tweenCameraToTooltip(pickedEntity, moveDuration, desiredCamPos);
261
+ }
262
+ });
263
+
264
+ // --- Helpers math/angles ---
265
+ function shortestAngleDiff(target, current) {
266
+ // Retourne l'écart angulaire [-180, 180] pour interpoler par le plus court chemin
267
+ let delta = target - current;
268
+ delta = ((delta + 180) % 360 + 360) % 360 - 180;
269
+ return delta;
270
+ }
271
+
272
+ /**
273
+ * Calcule {yaw, pitch, distance} pour une caméra à cameraPos regardant pivotPos,
274
+ * selon la convention de l'orbitCamera.
275
+ */
276
+ function computeOrbitFromPositions(cameraPos, pivotPos) {
277
+ const tempEnt = new pc.Entity();
278
+ tempEnt.setPosition(cameraPos);
279
+ tempEnt.lookAt(pivotPos);
280
+
281
+ const rotation = tempEnt.getRotation();
282
+
283
+ // Direction "forward" (de la caméra vers le pivot)
284
+ const forward = new pc.Vec3();
285
+ rotation.transformVector(pc.Vec3.FORWARD, forward);
286
+
287
+ // Yaw : rotation horizontale
288
+ const rawYaw = Math.atan2(-forward.x, -forward.z) * pc.math.RAD_TO_DEG;
289
+
290
+ // Pitch : on retire d'abord l'influence du yaw pour isoler la composante verticale
291
+ const yawQuat = new pc.Quat().setFromEulerAngles(0, -rawYaw, 0);
292
+ const rotNoYaw = new pc.Quat().mul2(yawQuat, rotation);
293
+ const fNoYaw = new pc.Vec3();
294
+ rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw);
295
+ const rawPitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG;
296
+
297
+ // Distance : norme du vecteur pivot - caméra
298
+ const toPivot = new pc.Vec3().sub2(pivotPos, cameraPos);
299
+ const dist = toPivot.length();
300
+
301
+ tempEnt.destroy();
302
+ return { yaw: rawYaw, pitch: rawPitch, distance: dist };
303
+ }
304
+
305
+ /**
306
+ * Animation caméra vers un tooltip.
307
+ * - tooltipEnt: entité du tooltip cliqué
308
+ * - duration: durée de l'interpolation (s)
309
+ * - overrideCamWorldPos (pc.Vec3|null): si fourni, la caméra ira EXACTEMENT à cette position
310
+ * tout en regardant le tooltip.
311
+ */
312
+ function tweenCameraToTooltip(tooltipEnt, duration, overrideCamWorldPos = null) {
313
+ const orbitCam = cameraEntity.script && cameraEntity.script.orbitCamera;
314
+ if (!orbitCam) return;
315
+
316
+ const targetPos = tooltipEnt.getPosition().clone();
317
+
318
+ // État initial (depuis l'orbitCamera)
319
+ const startPivot = orbitCam.pivotPoint.clone();
320
+ const startYaw = orbitCam._yaw;
321
+ const startPitch = orbitCam._pitch;
322
+ const startDist = orbitCam._distance;
323
+
324
+ // Valeurs finales à déterminer
325
+ let endPivot = targetPos.clone();
326
+ let endYaw, endPitch, endDist;
327
+
328
+ if (overrideCamWorldPos) {
329
+ // --- Nouveau mode : position caméra imposée par le JSON ---
330
+ const { yaw, pitch, distance } = computeOrbitFromPositions(overrideCamWorldPos, targetPos);
331
+ endYaw = startYaw + shortestAngleDiff(yaw, startYaw);
332
+ endPitch = startPitch + shortestAngleDiff(pitch, startPitch);
333
+ endDist = distance;
334
+ } else {
335
+ // --- Comportement historique (aucune camX/Y/Z fournie) ---
336
+ const worldRadius = 0.5 * tooltipEnt.getLocalScale().x;
337
+ const minZoom = orbitCam.distanceMin || 0.1;
338
+ const desiredDistance = Math.max(minZoom * 1.2, worldRadius * 4);
339
+
340
+ const camWorldPos = cameraEntity.getPosition().clone();
341
+ const { yaw, pitch } = computeOrbitFromPositions(camWorldPos, targetPos);
342
+
343
+ endYaw = startYaw + shortestAngleDiff(yaw, startYaw);
344
+ endPitch = startPitch + shortestAngleDiff(pitch, startPitch);
345
+ endDist = desiredDistance;
346
+ }
347
+
348
+ // Sauvegarde des origines pour l'interpolation
349
+ const orgPivot = startPivot.clone();
350
+ const orgYaw = startYaw;
351
+ const orgPitch = startPitch;
352
+ const orgDist = startDist;
353
+
354
+ let elapsed = 0;
355
+
356
+ // Si une interpolation était déjà en cours, on la débranche
357
+ if (currentTween) {
358
+ app.off("update", currentTween);
359
+ currentTween = null;
360
+ }
361
+
362
+ // --- Lerp frame-by-frame ---
363
+ function lerpUpdate(dt) {
364
+ elapsed += dt;
365
+ const t = Math.min(elapsed / duration, 1);
366
+
367
+ // Pivot (regard) vers le tooltip
368
+ const newPivot = new pc.Vec3().lerp(orgPivot, endPivot, t);
369
+ orbitCam.pivotPoint.copy(newPivot);
370
+
371
+ // Yaw, Pitch, Distance (on met aussi les "target" pour rester cohérent avec le script orbitCamera)
372
+ const newYaw = pc.math.lerp(orgYaw, endYaw, t);
373
+ const newPitch = pc.math.lerp(orgPitch, endPitch, t);
374
+ const newDist = pc.math.lerp(orgDist, endDist, t);
375
+
376
+ orbitCam._targetYaw = newYaw;
377
+ orbitCam._yaw = newYaw;
378
+ orbitCam._targetPitch = newPitch;
379
+ orbitCam._pitch = newPitch;
380
+ orbitCam._targetDistance = newDist;
381
+ orbitCam._distance = newDist;
382
+
383
+ // Mise à jour de la position monde de la caméra à partir des paramètres d'orbite
384
+ orbitCam._updatePosition();
385
+
386
+ if (t >= 1) {
387
+ app.off("update", lerpUpdate);
388
+ currentTween = null;
389
+ }
390
+ }
391
+
392
+ currentTween = lerpUpdate;
393
+ app.on("update", lerpUpdate);
394
+ }
395
+ }