// 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); } }