// tooltips.js // // - Charge le JSON depuis GitHub Raw (par défaut) via GET conditionnel (If-None-Match). // - 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. // - Supporte linkUrl/linkText pour afficher un bouton de lien externe dans le panneau. // // 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') // - 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; } } // --- Parse JSON sûr --- async function safeParseJson(resp) { const text = await resp.text(); try { return JSON.parse(text); } catch (e) { throw new Error("Invalid JSON"); } } /** * fetchWithETag(url, { cacheMode, bustOnError }) * - GET conditionnel avec If-None-Match → 304 si pas de changement * - Conserve ETag + data localement * - Fallback : sert la dernière copie valide, sinon GET "no-store" avec cache-buster */ 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 { const headers = {}; if (prevEtag) headers["If-None-Match"] = prevEtag; const resp = await fetch(url, { method: "GET", cache: cacheMode, mode: "cors", redirect: "follow", credentials: "omit", headers }); // 304 = pas de changement → réutilise la copie locale if (resp.status === 304 && prevData) return prevData; if (!resp.ok) { if (prevData) return prevData; throw new Error(`HTTP ${resp.status}`); } const json = await safeParseJson(resp); 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) { if (prevData) return prevData; // Ultime recours : cache-buster no-store 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); safeSetItem(LS_KEY_DATA, JSON.stringify(fresh)); return fresh; } catch { throw err; } } throw err; } } /** * initializeTooltips(options) * * - options.app : PlayCanvas AppBase instance * - options.cameraEntity : PlayCanvas camera Entity (script orbitCamera) * - 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 : mode de cache fetch (défaut 'no-cache') * * Format JSON attendu par tooltip : * { x, y, z, title, description, imgUrl, linkUrl, linkText, camX, camY, camZ } */ export async function initializeTooltips(options) { const { app, cameraEntity, tooltipsUrl = DEFAULT_TOOLTIPS_URL, defaultVisible, moveDuration = 0.6, cacheMode = "no-cache" } = options || {}; if (!app || !cameraEntity || !tooltipsUrl) return; // --- Chargement robuste --- let tooltipsData; try { tooltipsData = await fetchWithETag(tooltipsUrl, { cacheMode, bustOnError: true }); } catch { return; } if (!Array.isArray(tooltipsData)) return; const tooltipEntities = []; // --- Matériau des sphères (useLighting = false → specular/shininess sans effet) --- const mat = new pc.StandardMaterial(); mat.diffuse = new pc.Color(1, 0.8, 0); 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 --- for (let i = 0; i < tooltipsData.length; i++) { const tt = tooltipsData[i]; const { x, y, z, title, description, imgUrl, linkUrl, linkText, camX, camY, camZ } = tt; const sphere = new pc.Entity("tooltip-" + i); sphere.addComponent("model", { type: "sphere" }); sphere.model.material = mat; sphere.setLocalScale(0.05, 0.05, 0.05); sphere.setLocalPosition(x, y, z); sphere.tooltipData = { title, description, imgUrl, linkUrl, linkText, 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é --- function setTooltipsVisibility(visible) { tooltipEntities.forEach(ent => { ent.enabled = visible; }); } setTooltipsVisibility(!!defaultVisible); document.addEventListener("toggle-tooltips", (evt) => { setTooltipsVisibility(!!(evt.detail || {}).visible); }); // --- Ray picking (clic sur une sphère) --- let currentTween = null; app.mouse.on(pc.EVENT_MOUSEDOWN, (event) => { if (currentTween) { app.off("update", currentTween); currentTween = null; } const x = event.x, y = event.y; const from = new pc.Vec3(); const to = new pc.Vec3(); const camera = cameraEntity.camera; 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; 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) { const { title, description, imgUrl, linkUrl, linkText } = pickedEntity.tooltipData; document.dispatchEvent(new CustomEvent("tooltip-selected", { detail: { title, description, imgUrl, linkUrl, linkText } })); tweenCameraToTooltip(pickedEntity, moveDuration, pickedEntity.tooltipData.camTarget); } }); // --- Helpers angulaires --- function shortestAngleDiff(target, current) { let delta = target - current; delta = ((delta + 180) % 360 + 360) % 360 - 180; return delta; } /** * Calcule {yaw, pitch, distance} pour une caméra en 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(); const forward = new pc.Vec3(); rotation.transformVector(pc.Vec3.FORWARD, forward); const rawYaw = Math.atan2(-forward.x, -forward.z) * pc.math.RAD_TO_DEG; 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; const dist = new pc.Vec3().sub2(pivotPos, cameraPos).length(); tempEnt.destroy(); return { yaw: rawYaw, pitch: rawPitch, distance: dist }; } /** * Anime la caméra vers un tooltip. * - overrideCamWorldPos (pc.Vec3|null) : position caméra imposée par le JSON (camX/Y/Z) */ function tweenCameraToTooltip(tooltipEnt, duration, overrideCamWorldPos = null) { const orbitCam = cameraEntity.script && cameraEntity.script.orbitCamera; if (!orbitCam) return; const targetPos = tooltipEnt.getPosition().clone(); const startPivot = orbitCam.pivotPoint.clone(); const startYaw = orbitCam._yaw; const startPitch = orbitCam._pitch; const startDist = orbitCam._distance; let endPivot = targetPos.clone(); let endYaw, endPitch, endDist; if (overrideCamWorldPos) { const { yaw, pitch, distance } = computeOrbitFromPositions(overrideCamWorldPos, targetPos); endYaw = startYaw + shortestAngleDiff(yaw, startYaw); endPitch = startPitch + shortestAngleDiff(pitch, startPitch); endDist = distance; } else { const worldRadius = 0.5 * tooltipEnt.getLocalScale().x; const minZoom = orbitCam.distanceMin || 0.1; const desiredDistance = Math.max(minZoom * 1.2, worldRadius * 4); const { yaw, pitch } = computeOrbitFromPositions(cameraEntity.getPosition().clone(), targetPos); endYaw = startYaw + shortestAngleDiff(yaw, startYaw); endPitch = startPitch + shortestAngleDiff(pitch, startPitch); endDist = desiredDistance; } const orgPivot = startPivot.clone(); const orgYaw = startYaw; const orgPitch = startPitch; const orgDist = startDist; let elapsed = 0; if (currentTween) { app.off("update", currentTween); currentTween = null; } function lerpUpdate(dt) { elapsed += dt; const t = Math.min(elapsed / duration, 1); const newPivot = new pc.Vec3().lerp(orgPivot, endPivot, t); orbitCam.pivotPoint.copy(newPivot); 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; orbitCam._updatePosition(); if (t >= 1) { app.off("update", lerpUpdate); currentTween = null; } } currentTween = lerpUpdate; app.on("update", lerpUpdate); } }