Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 | 1x 1x 1x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 14x 14x 11x 11x 11x 9x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 2x | /**
* Libellés 3D : sprites « billboard » (toujours face caméra) rendus depuis un canvas.
*
* En 3D, afficher un libellé pour chaque nœud rend la scène illisible : `GraphView3D`
* n'en affiche que pour le nœud sélectionné et son voisinage (cf. spec AC-3.3/3.4).
* Le module est isolé pour rester mockable sous jsdom (pas de canvas 2D ni de WebGL).
*/
import * as THREE from 'three';
import { truncate } from '../../utils/text';
/** Longueur maximale d'un libellé dans la scène (même règle qu'en 2D). */
export const LABEL_MAX_CHARS = 24;
/**
* Hauteur d'écran d'un libellé, en fraction de la distance caméra : c'est la
* norme 2D (libellé toujours lisible, quelle que soit la profondeur) transposée
* en 3D. À 300 unités de distance, le libellé fait ~15 unités monde.
*/
export const LABEL_SCREEN_RATIO = 0.05;
/** Distance entre le centre du nœud et le bas du libellé (rayon + marge). */
const NODE_TOP_OFFSET = 9;
/**
* Re-dimensionne un libellé pour qu'il garde une taille d'écran constante.
* @param {THREE.Sprite} sprite
* @param {number} distance distance caméra → cible d'orbite
*/
export function scaleLabelSprite(sprite, distance) {
if (!sprite || !Number.isFinite(distance) || distance <= 0) return;
const aspect = sprite.userData?.aspect || 1;
const height = distance * LABEL_SCREEN_RATIO;
sprite.scale.set(aspect * height, height, 1);
// Décalage au-dessus de la sphère : proportionnel à la hauteur du libellé pour
// qu'il ne recouvre jamais le nœud, quelle que soit la distance (norme 2D :
// libellé au-dessus de la pastille, jamais dessus).
sprite.position?.set?.(0, height / 2 + NODE_TOP_OFFSET, 0);
}
/** Hauteur du sprite en unités monde (les sphères de nœuds font ~4 de rayon). */
const SPRITE_HEIGHT = 9;
/** Rapport de pixels borné : au-delà, la texture devient inutilement lourde. */
const MAX_PIXEL_RATIO = 2;
const FONT_SIZE = 42;
const FONT = `400 ${FONT_SIZE}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
const FONT_BOLD = `700 ${FONT_SIZE}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
const PADDING_X = 6;
/**
* Fabrique un sprite de libellé.
*
* Normes graphiques du graphe 2D : texte **sans pastille** (la 2D n'en a pas),
* slate-700, `font-weight` 700 pour le nœud sélectionné, troncature à 24
* caractères. Un halo blanc assure la lisibilité au-dessus des arêtes.
*
* @param {*} label libellé brut (tronqué à `LABEL_MAX_CHARS`)
* @param {{ selected?: boolean, color?: string }} [options]
* @returns {THREE.Sprite|null} `null` si le rendu canvas est indisponible.
*/
export function makeLabelSprite(label, { selected = false, color = '#334155' } = {}) {
const text = truncate(label, LABEL_MAX_CHARS).trim();
if (!text) return null;
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.font = selected ? FONT_BOLD : FONT;
const textWidth = Math.ceil(ctx.measureText(text).width) || FONT_SIZE * text.length * 0.6;
const width = textWidth + PADDING_X * 2;
const height = FONT_SIZE + 26;
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
canvas.width = Math.ceil(width * ratio);
canvas.height = Math.ceil(height * ratio);
// Le redimensionnement du canvas réinitialise le contexte : on le règle après.
ctx.scale(ratio, ratio);
ctx.font = selected ? FONT_BOLD : FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Halo clair : lisibilité sur le fond slate-50 et par-dessus les arêtes.
ctx.lineWidth = 6;
ctx.strokeStyle = 'rgba(248, 250, 252, 0.92)';
ctx.strokeText(text, width / 2, height / 2 + 1);
ctx.fillStyle = selected ? '#0f172a' : color;
ctx.fillText(text, width / 2, height / 2 + 1);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false, // le libellé reste lisible même derrière un nœud
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
// Le ratio est conservé pour pouvoir re-dimensionner le libellé à taille
// d'écran constante quand la caméra s'éloigne (cf. `scaleLabelSprite`).
sprite.userData.aspect = width / height;
sprite.scale.set((width / height) * SPRITE_HEIGHT, SPRITE_HEIGHT, 1);
sprite.renderOrder = 3;
return sprite;
} catch {
// Canvas/WebGL indisponible : pas de libellé, la vue 3D reste utilisable.
return null;
}
}
|