Spaces:
Running
Running
| // tooltips.js | |
| // | |
| // - Charge le JSON depuis GitHub Raw (par défaut) en utilisant un GET conditionnel (If-None-Match), | |
| // ce qui évite les problèmes Safari/iOS liés aux requêtes HEAD et maximise la compatibilité CORS. | |
| // - Met en cache la dernière version valide (localStorage si possible, sinon repli mémoire). | |
| // - En cas d'erreur réseau/parse, retombe sur la dernière copie valide, sinon tente un GET avec cache-buster. | |
| // - Supporte camX/camY/camZ pour placer la caméra exactement à ces coordonnées et regarder le tooltip. | |
| // | |
| // Utilisation minimale : | |
| // initializeTooltips({ app, cameraEntity, defaultVisible: true }) | |
| // | |
| // Options utiles : | |
| // - tooltipsUrl : pour surcharger l'URL (défaut : DEFAULT_TOOLTIPS_URL) | |
| // - cacheMode : 'default' | 'no-cache' | 'reload' | 'no-store' (défaut : 'no-cache' afin de revalider proprement) | |
| // - moveDuration: durée (s) de l’animation caméra | |
| const DEFAULT_TOOLTIPS_URL = | |
| "https://raw.githubusercontent.com/mika-fi/sgos_dataset/main/exemples/baleine/tooltips.json"; | |
| // --- Stockage sûr (localStorage protégé + repli mémoire pour iOS privé / ITP / iframes) --- | |
| const __memStore = Object.create(null); | |
| function safeGetItem(key) { | |
| try { return localStorage.getItem(key); } catch (_) { return __memStore[key] ?? null; } | |
| } | |
| function safeSetItem(key, value) { | |
| try { localStorage.setItem(key, value); } catch (_) { __memStore[key] = value; } | |
| } | |
| function safeRemoveItem(key) { | |
| try { localStorage.removeItem(key); } catch (_) { delete __memStore[key]; } | |
| } | |
| // --- Parse JSON sûr (évite de crasher le flux en cas de contenu invalide) --- | |
| async function safeParseJson(resp) { | |
| const text = await resp.text(); | |
| try { | |
| return JSON.parse(text); | |
| } catch (e) { | |
| // JSON invalide (rare sur GitHub Raw, mais mieux vaut prévenir) | |
| throw new Error("Invalid JSON"); | |
| } | |
| } | |
| /** | |
| * fetchWithETag(url, { cacheMode, bustOnError }) | |
| * - GET conditionnel avec If-None-Match pour obtenir 304 si pas de changement | |
| * - Conserve ETag + data localement | |
| * - Fallback : sert la dernière copie valide, sinon GET "no-store" avec cache-buster | |
| * - Évite les HEAD (souvent source d'ennuis avec Safari/CORS/redirections) | |
| */ | |
| async function fetchWithETag(url, { cacheMode = "no-cache", bustOnError = true } = {}) { | |
| const LS_KEY_DATA = `tooltips:data:${url}`; | |
| const LS_KEY_ETAG = `tooltips:etag:${url}`; | |
| const prevEtag = safeGetItem(LS_KEY_ETAG); | |
| const prevDataStr = safeGetItem(LS_KEY_DATA); | |
| const prevData = prevDataStr ? (() => { try { return JSON.parse(prevDataStr); } catch { return null; } })() : null; | |
| try { | |
| // 1) GET conditionnel (If-None-Match) pour maximiser la compat safari/ios et CDN | |
| const headers = {}; | |
| if (prevEtag) headers["If-None-Match"] = prevEtag; | |
| const resp = await fetch(url, { | |
| method: "GET", | |
| cache: cacheMode, // 'no-cache' pour revalidation, 'no-store' pour refetch strict | |
| mode: "cors", | |
| redirect: "follow", | |
| credentials: "omit", | |
| headers | |
| }); | |
| // 304 = pas de changement -> réutilise la dernière copie locale | |
| if (resp.status === 304 && prevData) { | |
| return prevData; | |
| } | |
| if (!resp.ok) { | |
| if (prevData) return prevData; | |
| throw new Error(`HTTP ${resp.status}`); | |
| } | |
| // 2) Nouvelle version téléchargée | |
| const json = await safeParseJson(resp); | |
| // 3) Enregistre ETag + data | |
| const newEtag = resp.headers.get("ETag") || resp.headers.get("etag") || null; | |
| if (newEtag) safeSetItem(LS_KEY_ETAG, newEtag); | |
| safeSetItem(LS_KEY_DATA, JSON.stringify(json)); | |
| return json; | |
| } catch (err) { | |
| // 4) Fallback : si échec réseau/parse, renvoie la dernière copie locale si dispo | |
| if (prevData) return prevData; | |
| // 5) Ultime recours : cache-buster no-store (utile contre caches "têtus" de Safari/iOS) | |
| if (bustOnError) { | |
| const sep = url.includes("?") ? "&" : "?"; | |
| const bustedUrl = `${url}${sep}t=${Date.now()}`; | |
| try { | |
| const bustResp = await fetch(bustedUrl, { | |
| method: "GET", | |
| cache: "no-store", | |
| mode: "cors", | |
| redirect: "follow", | |
| credentials: "omit" | |
| }); | |
| if (!bustResp.ok) throw new Error(`HTTP ${bustResp.status}`); | |
| const fresh = await safeParseJson(bustResp); | |
| // sauvegarde sans ETag (inconnu sur la requête bustée) | |
| safeSetItem(LS_KEY_DATA, JSON.stringify(fresh)); | |
| return fresh; | |
| } catch (e2) { | |
| throw err; // On remonte l'erreur d'origine si tout échoue | |
| } | |
| } | |
| throw err; | |
| } | |
| } | |
| /** | |
| * initializeTooltips(options) | |
| * | |
| * - options.app: the PlayCanvas AppBase instance | |
| * - options.cameraEntity: PlayCanvas camera Entity (utilisant le script orbitCamera) | |
| * - options.modelEntity: the main model entity (for relative positioning, optional) | |
| * - options.tooltipsUrl: URL du JSON (optionnel, défaut = DEFAULT_TOOLTIPS_URL) | |
| * - options.defaultVisible: booléen : tooltips visibles au démarrage | |
| * - options.moveDuration: durée (s) de l’animation caméra (défaut 0.6) | |
| * - options.cacheMode: 'default' | 'no-cache' | 'reload' | 'no-store' (défaut 'no-cache' → revalidation correcte) | |
| */ | |
| export async function initializeTooltips(options) { | |
| const { | |
| app, | |
| cameraEntity, | |
| modelEntity, // conservé pour compat | |
| tooltipsUrl = DEFAULT_TOOLTIPS_URL, | |
| defaultVisible, | |
| moveDuration = 0.6, | |
| cacheMode = "no-cache" | |
| } = options || {}; | |
| if (!app || !cameraEntity || !tooltipsUrl) return; | |
| // --- Chargement robuste des tooltips (dernière version + fallback) --- | |
| let tooltipsData; | |
| try { | |
| tooltipsData = await fetchWithETag(tooltipsUrl, { cacheMode, bustOnError: true }); | |
| } catch (e) { | |
| // Aucune donnée exploitable -> on abandonne proprement | |
| return; | |
| } | |
| if (!Array.isArray(tooltipsData)) return; | |
| const tooltipEntities = []; | |
| // --- Matériau des sphères (tooltips) --- | |
| const mat = new pc.StandardMaterial(); | |
| mat.diffuse = new pc.Color(1, 0.8, 0); | |
| mat.specular = new pc.Color(1, 1, 1); | |
| mat.shininess = 20; | |
| mat.emissive = new pc.Color(0.85, 0.85, 0.85); | |
| mat.emissiveIntensity = 1; | |
| mat.useLighting = false; | |
| mat.update(); | |
| // --- Création des entités sphères pour chaque tooltip --- | |
| for (let i = 0; i < tooltipsData.length; i++) { | |
| const tt = tooltipsData[i]; | |
| const { x, y, z, title, description, imgUrl, camX, camY, camZ } = tt; | |
| const sphere = new pc.Entity("tooltip-" + i); | |
| sphere.addComponent("model", { type: "sphere" }); | |
| sphere.model.material = mat; | |
| // Taille par défaut des "pins" | |
| sphere.setLocalScale(0.05, 0.05, 0.05); | |
| sphere.setLocalPosition(x, y, z); | |
| // Infos du tooltip (UI + cible caméra optionnelle) | |
| sphere.tooltipData = { | |
| title, | |
| description, | |
| imgUrl, | |
| camTarget: | |
| Number.isFinite(camX) && Number.isFinite(camY) && Number.isFinite(camZ) | |
| ? new pc.Vec3(camX, camY, camZ) | |
| : null | |
| }; | |
| app.root.addChild(sphere); | |
| tooltipEntities.push(sphere); | |
| } | |
| // --- Visibilité initiale + contrôle externe --- | |
| function setTooltipsVisibility(visible) { | |
| tooltipEntities.forEach(ent => { ent.enabled = visible; }); | |
| } | |
| setTooltipsVisibility(!!defaultVisible); | |
| // Écouteur externe (ex. UI HTML) pour afficher/masquer les tooltips | |
| document.addEventListener("toggle-tooltips", (evt) => { | |
| const { visible } = evt.detail || {}; | |
| setTooltipsVisibility(!!visible); | |
| }); | |
| // --- Picking (détection de clic sur un tooltip) --- | |
| let currentTween = null; | |
| app.mouse.on(pc.EVENT_MOUSEDOWN, (event) => { | |
| // Si une interpolation est en cours, on l'arrête proprement | |
| if (currentTween) { | |
| app.off("update", currentTween); | |
| currentTween = null; | |
| } | |
| const x = event.x, y = event.y; | |
| const from = new pc.Vec3(), to = new pc.Vec3(); | |
| const camera = cameraEntity.camera; | |
| // Ray picking écran -> monde | |
| camera.screenToWorld(x, y, camera.nearClip, from); | |
| camera.screenToWorld(x, y, camera.farClip, to); | |
| const dir = new pc.Vec3().sub2(to, from).normalize(); | |
| let closestT = Infinity; | |
| let pickedEntity = null; | |
| // Test d'intersection rayon/sphère (simple et suffisant ici) | |
| for (const ent of tooltipEntities) { | |
| if (!ent.enabled) continue; | |
| const center = ent.getPosition(); | |
| const worldRadius = 0.5 * ent.getLocalScale().x; | |
| const oc = new pc.Vec3().sub2(center, from); | |
| const tca = oc.dot(dir); | |
| if (tca < 0) continue; | |
| const d2 = oc.lengthSq() - (tca * tca); | |
| if (d2 > worldRadius * worldRadius) continue; | |
| const thc = Math.sqrt(worldRadius * worldRadius - d2); | |
| const t0 = tca - thc; | |
| if (t0 < closestT && t0 >= 0) { | |
| closestT = t0; | |
| pickedEntity = ent; | |
| } | |
| } | |
| if (pickedEntity) { | |
| // Notifier l'UI (titre, description, image) | |
| const { title, description, imgUrl } = pickedEntity.tooltipData; | |
| document.dispatchEvent(new CustomEvent("tooltip-selected", { | |
| detail: { title, description, imgUrl } | |
| })); | |
| // Si on a une position caméra cible, on l'utilise | |
| const desiredCamPos = pickedEntity.tooltipData.camTarget; | |
| tweenCameraToTooltip(pickedEntity, moveDuration, desiredCamPos); | |
| } | |
| }); | |
| // --- Helpers math/angles --- | |
| function shortestAngleDiff(target, current) { | |
| // Retourne l'écart angulaire [-180, 180] pour interpoler par le plus court chemin | |
| let delta = target - current; | |
| delta = ((delta + 180) % 360 + 360) % 360 - 180; | |
| return delta; | |
| } | |
| /** | |
| * Calcule {yaw, pitch, distance} pour une caméra à cameraPos regardant pivotPos, | |
| * selon la convention de l'orbitCamera. | |
| */ | |
| function computeOrbitFromPositions(cameraPos, pivotPos) { | |
| const tempEnt = new pc.Entity(); | |
| tempEnt.setPosition(cameraPos); | |
| tempEnt.lookAt(pivotPos); | |
| const rotation = tempEnt.getRotation(); | |
| // Direction "forward" (de la caméra vers le pivot) | |
| const forward = new pc.Vec3(); | |
| rotation.transformVector(pc.Vec3.FORWARD, forward); | |
| // Yaw : rotation horizontale | |
| const rawYaw = Math.atan2(-forward.x, -forward.z) * pc.math.RAD_TO_DEG; | |
| // Pitch : on retire d'abord l'influence du yaw pour isoler la composante verticale | |
| const yawQuat = new pc.Quat().setFromEulerAngles(0, -rawYaw, 0); | |
| const rotNoYaw = new pc.Quat().mul2(yawQuat, rotation); | |
| const fNoYaw = new pc.Vec3(); | |
| rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw); | |
| const rawPitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG; | |
| // Distance : norme du vecteur pivot - caméra | |
| const toPivot = new pc.Vec3().sub2(pivotPos, cameraPos); | |
| const dist = toPivot.length(); | |
| tempEnt.destroy(); | |
| return { yaw: rawYaw, pitch: rawPitch, distance: dist }; | |
| } | |
| /** | |
| * Animation caméra vers un tooltip. | |
| * - tooltipEnt: entité du tooltip cliqué | |
| * - duration: durée de l'interpolation (s) | |
| * - overrideCamWorldPos (pc.Vec3|null): si fourni, la caméra ira EXACTEMENT à cette position | |
| * tout en regardant le tooltip. | |
| */ | |
| function tweenCameraToTooltip(tooltipEnt, duration, overrideCamWorldPos = null) { | |
| const orbitCam = cameraEntity.script && cameraEntity.script.orbitCamera; | |
| if (!orbitCam) return; | |
| const targetPos = tooltipEnt.getPosition().clone(); | |
| // État initial (depuis l'orbitCamera) | |
| const startPivot = orbitCam.pivotPoint.clone(); | |
| const startYaw = orbitCam._yaw; | |
| const startPitch = orbitCam._pitch; | |
| const startDist = orbitCam._distance; | |
| // Valeurs finales à déterminer | |
| let endPivot = targetPos.clone(); | |
| let endYaw, endPitch, endDist; | |
| if (overrideCamWorldPos) { | |
| // --- Nouveau mode : position caméra imposée par le JSON --- | |
| const { yaw, pitch, distance } = computeOrbitFromPositions(overrideCamWorldPos, targetPos); | |
| endYaw = startYaw + shortestAngleDiff(yaw, startYaw); | |
| endPitch = startPitch + shortestAngleDiff(pitch, startPitch); | |
| endDist = distance; | |
| } else { | |
| // --- Comportement historique (aucune camX/Y/Z fournie) --- | |
| const worldRadius = 0.5 * tooltipEnt.getLocalScale().x; | |
| const minZoom = orbitCam.distanceMin || 0.1; | |
| const desiredDistance = Math.max(minZoom * 1.2, worldRadius * 4); | |
| const camWorldPos = cameraEntity.getPosition().clone(); | |
| const { yaw, pitch } = computeOrbitFromPositions(camWorldPos, targetPos); | |
| endYaw = startYaw + shortestAngleDiff(yaw, startYaw); | |
| endPitch = startPitch + shortestAngleDiff(pitch, startPitch); | |
| endDist = desiredDistance; | |
| } | |
| // Sauvegarde des origines pour l'interpolation | |
| const orgPivot = startPivot.clone(); | |
| const orgYaw = startYaw; | |
| const orgPitch = startPitch; | |
| const orgDist = startDist; | |
| let elapsed = 0; | |
| // Si une interpolation était déjà en cours, on la débranche | |
| if (currentTween) { | |
| app.off("update", currentTween); | |
| currentTween = null; | |
| } | |
| // --- Lerp frame-by-frame --- | |
| function lerpUpdate(dt) { | |
| elapsed += dt; | |
| const t = Math.min(elapsed / duration, 1); | |
| // Pivot (regard) vers le tooltip | |
| const newPivot = new pc.Vec3().lerp(orgPivot, endPivot, t); | |
| orbitCam.pivotPoint.copy(newPivot); | |
| // Yaw, Pitch, Distance (on met aussi les "target" pour rester cohérent avec le script orbitCamera) | |
| const newYaw = pc.math.lerp(orgYaw, endYaw, t); | |
| const newPitch = pc.math.lerp(orgPitch, endPitch, t); | |
| const newDist = pc.math.lerp(orgDist, endDist, t); | |
| orbitCam._targetYaw = newYaw; | |
| orbitCam._yaw = newYaw; | |
| orbitCam._targetPitch = newPitch; | |
| orbitCam._pitch = newPitch; | |
| orbitCam._targetDistance = newDist; | |
| orbitCam._distance = newDist; | |
| // Mise à jour de la position monde de la caméra à partir des paramètres d'orbite | |
| orbitCam._updatePosition(); | |
| if (t >= 1) { | |
| app.off("update", lerpUpdate); | |
| currentTween = null; | |
| } | |
| } | |
| currentTween = lerpUpdate; | |
| app.on("update", lerpUpdate); | |
| } | |
| } | |