Sog_Viewer / tooltips.js
MikaFil's picture
Update tooltips.js
0b1fb23 verified
// 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);
}
}