viewer_sgos / tooltips.js
MikaFil's picture
Update tooltips.js
702948c verified
// 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);
}
}