From 063bd629c2a137a7f9b5c3d1c743c47d5edc6ce3 Mon Sep 17 00:00:00 2001 From: Rodrigo Bertoldo de Sousa Martins Date: Thu, 10 Sep 2026 19:19:31 -0300 Subject: [PATCH] tegumentar2 --- data-pipeline/scripts/build-seed-models.mjs | 7 +- data-pipeline/scripts/geometry.mjs | 18 ++- data-pipeline/seed/build-manifest.mjs | 151 +++++++++++--------- frontend/src/components/QuickRegionNav.tsx | 121 ++++++++++++++-- 4 files changed, 213 insertions(+), 84 deletions(-) diff --git a/data-pipeline/scripts/build-seed-models.mjs b/data-pipeline/scripts/build-seed-models.mjs index 3a5def5..816293a 100644 --- a/data-pipeline/scripts/build-seed-models.mjs +++ b/data-pipeline/scripts/build-seed-models.mjs @@ -63,7 +63,12 @@ for (const [systemSlug, items] of bySystem) { .createMaterial(hex) .setBaseColorFactor(hexToRgb(hex)) .setRoughnessFactor(0.6) - .setMetallicFactor(0.05); + .setMetallicFactor(0.05) + // O bloco de pele usa caixas com a face da frente removida (ver + // openFrontBox em geometry.mjs) pra dar pra ver o conteúdo por + // dentro — sem dupla face, as paredes de trás/lados ficariam + // invisíveis quando vistas "de dentro" da caixa aberta. + .setDoubleSided(true); materialCache.set(hex, mat); return mat; } diff --git a/data-pipeline/scripts/geometry.mjs b/data-pipeline/scripts/geometry.mjs index c6a46e5..dc63268 100644 --- a/data-pipeline/scripts/geometry.mjs +++ b/data-pipeline/scripts/geometry.mjs @@ -1,7 +1,11 @@ // Minimal parametric mesh generators (box/sphere/cylinder), dependency-free. // Returns { positions: Float32Array, normals: Float32Array, indices: Uint32Array }. -export function box(w, h, d) { +// skipNormals: normais (como "0,-1,0") das faces a NÃO gerar — usado pelo +// bloco de pele em corte (openFrontBox abaixo) pra deixar a face da frente +// aberta, como uma caixa de diorama, revelando o conteúdo interno em vez de +// escondê-lo atrás de uma parede opaca. +export function box(w, h, d, skipNormals = []) { const x = w / 2, y = h / 2, z = d / 2; const positions = []; const normals = []; @@ -17,7 +21,10 @@ export function box(w, h, d) { [[-1, 0, 0], [[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]]], ]; + const skip = new Set(skipNormals.map((n) => n.join(","))); + for (const [n, corners] of faces) { + if (skip.has(n.join(","))) continue; const base = positions.length / 3; for (const c of corners) { positions.push(...c); @@ -33,6 +40,14 @@ export function box(w, h, d) { }; } +// Caixa "de diorama": todas as faces normais, exceto a de baixo em Y +// (nosso eixo "frente/trás" no bloco de pele) — deixa a frente aberta pra +// dar pra ver o conteúdo (folículos, glândulas, vasos) por dentro, em vez +// de uma parede opaca escondendo tudo. +export function openFrontBox(w, h, d) { + return box(w, h, d, [[0, -1, 0]]); +} + export function sphere(radius, widthSegments = 16, heightSegments = 12) { const positions = []; const normals = []; @@ -123,6 +138,7 @@ export function cylinder(radiusTop, radiusBottom, height, radialSegments = 12) { export function buildGeometry(shape) { switch (shape.type) { case "box": return box(shape.w, shape.h, shape.d); + case "openFrontBox": return openFrontBox(shape.w, shape.h, shape.d); case "sphere": return sphere(shape.r); case "cylinder": return cylinder(shape.rt, shape.rb, shape.h); default: throw new Error(`Unknown shape type: ${shape.type}`); diff --git a/data-pipeline/seed/build-manifest.mjs b/data-pipeline/seed/build-manifest.mjs index 37aead0..26447e3 100644 --- a/data-pipeline/seed/build-manifest.mjs +++ b/data-pipeline/seed/build-manifest.mjs @@ -1,28 +1,24 @@ // Gera manifest-tegumentar.json: o Z-Anatomy-Sample não inclui camadas de // pele (epiderme/derme/hipoderme) nem seus anexos (folículo piloso, glândulas -// etc.) — são estruturas microscópicas, nenhum dataset de corpo inteiro as +// etc.) — confirmado consultando a listagem completa de +// Assets/Models/1.0 Models no repositório (só Skeletal, Muscular, Nervous, +// CardioVascular, Visceral, Joints, LymphoidOrgans, Regions e Reference +// lines) — são estruturas microscópicas, nenhum dataset de corpo inteiro as // modela como malha. Por isso o sistema tegumentar (só ele — os demais // sistemas continuam vindo de malhas reais via scripts/extract-manifest.mjs, // sem nenhuma mudança) usa um "bloco" esquemático construído com geometria // paramétrica simples (ver scripts/geometry.mjs e scripts/build-seed-models.mjs, // também exclusivos do tegumentar — só o script "build:tegumentar" os chama). // -// MODELO EM DEGRAUS ("staircase cutaway"), como os blocos didáticos de pele -// usados em aula: cada camada mais profunda tem uma extensão maior no eixo Y -// do que a camada acima dela, então a camada de cima NÃO cobre a de baixo -// inteira — sobra um "degrau" onde a superfície da camada mais profunda fica -// exposta e visível de cima, em vez de tudo empilhado e escondido atrás da -// camada mais externa (o problema do modelo anterior, que empilhava 3 caixas -// finas exatamente uma sobre a outra). -// epiderme: ████ (degrau 1 — só ela) -// derme: ████ ████ (degraus 1+2 — expõe o degrau 2) -// hipoderme: ████ ████ ████ (degraus 1+2+3 — expõe o degrau 3) -// A parede vertical no degrau 1 (voltada para -Y) mostra as 3 camadas -// empilhadas de uma vez, como um corte transversal — é ali na frente que -// ficam os componentes (pelo, glândulas, vasos, corpúsculos), flutuando bem -// próximos da parede (fora do bloco opaco, pra nunca ficarem escondidos -// dentro dele) e alinhados em X e Z com a camada a que pertencem. Gire o -// modelo com o mouse pra ver o bloco de todos os ângulos. +// MODELO "DIORAMA": uma caixa sólida (as 3 camadas empilhadas, cada uma uma +// openFrontBox — ver geometry.mjs) com a face da frente removida, como uma +// caixa de vidro sem uma parede, no estilo do desenho clássico de corte de +// pele (Netter/Sobotta): de cima dá pra ver a superfície da epiderme com os +// pelos saindo; do lado dá pra ver a "banda" de cor de cada camada; e pela +// frente aberta dá pra ver os componentes (folículos, glândulas, vasos, +// corpúsculos) soltos dentro do volume da derme/hipoderme, sem nada opaco +// na frente escondendo-os. Gire o modelo com o mouse pra explorar de vários +// ângulos. import { writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -31,18 +27,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const structures = []; -// O Z-Anatomy-Sample (verificado na íntegra em Assets/Models/1.0 Models, -// via API do GitHub) NÃO tem nenhum arquivo de pele/tegumento — só -// SkeletalSystem, MuscularSystem, NervousSystem, CardioVascular, -// VisceralSystem, Joints, LymphoidOrgans, Regions e Reference lines — -// confirma o que já estava documentado: não existe dataset de corpo -// inteiro com camadas de pele, daqui vem a necessidade do bloco esquemático. -// // Todo o desenho abaixo é escrito em "unidades de projeto" (a escala // original, pensada peça por peça) e só na hora de gerar a estrutura final // o add() aplica o fator SCALE — assim dá pra aumentar/diminuir o bloco -// inteiro (pedido do usuário: "aumente o tamanho dele") mudando um único -// número, sem precisar recalcular à mão a posição de cada uma das ~20 peças. +// inteiro mudando um único número, sem recalcular à mão a posição de cada +// uma das peças. const SCALE = 3; function scaleShape(shape) { @@ -78,7 +67,7 @@ function add(system, slug, namePt, nameEn, region, shape, position, opts = {}) { }); } -const TX = ["Sistema Tegumentar", "Corte Transversal da Pele (em Degraus)"]; +const TX = ["Sistema Tegumentar", "Corte Transversal da Pele (Diorama)"]; // Centro/âncora do bloco (coordenadas do mundo, mesma escala do corpo // Z-Anatomy — colado na face do antebraço, como antes). @@ -86,71 +75,69 @@ const CX = 0.24; const CY = 0.85; const BLOCK_W = 0.14; // extensão em X (largura do bloco) -const STEP = 0.05; // profundidade em Y de cada degrau -const Y_FRONT = CY - 1.5 * STEP; // borda frontal do bloco (degrau 1), onde fica a parede de corte +const Y_DEPTH = 0.12; // profundidade em Y (frente-trás) — a mesma pras 3 camadas +const Y_FRONT = CY - Y_DEPTH / 2; // onde fica a abertura (face removida) // Espessura (eixo Z) de cada camada e a profundidade onde cada uma começa/ // termina — definidas uma vez aqui pra tudo (camadas E componentes) usar os -// mesmos números e não desalinhar. +// mesmos números e não desalinhar. Proporções exageradas (derme/hipoderme +// bem mais grossas que na realidade) de propósito, pra caber e destacar os +// componentes — como em qualquer diagrama didático de pele. const Z_TOP = 0.038; // superfície da epiderme -const D_EPI = 0.005; -const D_DERME = 0.012; -const D_HIPO = 0.016; -const Z_EPI_BOTTOM = Z_TOP - D_EPI; // 0.033 = limite epiderme/derme -const Z_DERME_BOTTOM = Z_EPI_BOTTOM - D_DERME; // 0.021 = limite derme/hipoderme -const Z_HIPO_BOTTOM = Z_DERME_BOTTOM - D_HIPO; // 0.005 = base do bloco +const D_EPI = 0.012; +const D_DERME = 0.035; +const D_HIPO = 0.053; +const Z_EPI_BOTTOM = Z_TOP - D_EPI; // limite epiderme/derme +const Z_DERME_BOTTOM = Z_EPI_BOTTOM - D_DERME; // limite derme/hipoderme +const Z_HIPO_BOTTOM = Z_DERME_BOTTOM - D_HIPO; // base do bloco -// Os componentes (pelo, glândulas, vasos, corpúsculos...) ficam flutuando -// bem à frente da parede de corte (fora do volume opaco do bloco, pra nunca -// serem encobertos por ele) — cada "coluna" X é um componente diferente, -// como um desenho de corte histológico visto de frente. -const FRONT_Y = Y_FRONT - 0.01; const ROT_Z = [Math.PI / 2, 0, 0]; // deixa cilindros na vertical (eixo nasce em Y, isso gira pra Z) -// ---- As 3 camadas da pele, em degraus ---- +// ---- As 3 camadas da pele, como uma caixa de diorama (frente aberta) ---- add( "tegumentar", "epiderme", "Epiderme", "Epidermis", [...TX], - { type: "box", w: BLOCK_W, h: STEP, d: D_EPI }, - [CX, Y_FRONT + STEP / 2, Z_TOP - D_EPI / 2], + { type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_EPI }, + [CX, CY, Z_TOP - D_EPI / 2], { color: "#e0a96d", description: - "Camada mais externa e avascular da pele, formada por epitélio pavimentoso estratificado queratinizado; sua função é a principal barreira contra perda de água, atrito, micro-organismos e raios UV (com a ajuda da melanina).", + "Camada mais externa e avascular da pele, formada por epitélio pavimentoso estratificado queratinizado; sua função é a principal barreira contra perda de água, atrito, micro-organismos e raios UV (com a ajuda da melanina). É a face de cima do bloco, de onde saem os pelos.", }, ); add( "tegumentar", "derme", "Derme", "Dermis", [...TX], - { type: "box", w: BLOCK_W, h: 2 * STEP, d: D_DERME }, - [CX, Y_FRONT + STEP, Z_EPI_BOTTOM - D_DERME / 2], + { type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_DERME }, + [CX, CY, Z_EPI_BOTTOM - D_DERME / 2], { color: "#c97b4a", description: - "Camada de tecido conjuntivo rica em colágeno e elastina, vasos sanguíneos, terminações nervosas e os anexos cutâneos (folículos pilosos, glândulas); sustenta a epiderme e dá à pele resistência e elasticidade. Repare no degrau exposto: é a derme aparecendo onde a epiderme já não cobre mais.", + "Camada de tecido conjuntivo rica em colágeno e elastina, vasos sanguíneos, terminações nervosas e os anexos cutâneos (folículos pilosos, glândulas); sustenta a epiderme e dá à pele resistência e elasticidade. Visível como a faixa do meio no lado do bloco.", }, ); add( "tegumentar", "hipoderme", "Hipoderme (Tecido Subcutâneo)", "Hypodermis (Subcutaneous Tissue)", [...TX], - { type: "box", w: BLOCK_W, h: 3 * STEP, d: D_HIPO }, + { type: "openFrontBox", w: BLOCK_W, h: Y_DEPTH, d: D_HIPO }, [CX, CY, Z_DERME_BOTTOM - D_HIPO / 2], { color: "#f2d4a3", description: - "Camada mais profunda, rica em tecido adiposo; isola termicamente, absorve impactos e serve de reserva de energia — não é tecnicamente parte da pele, mas a conecta aos músculos e ossos subjacentes. É o degrau mais largo, exposto onde nem epiderme nem derme mais chegam.", + "Camada mais profunda, rica em tecido adiposo; isola termicamente, absorve impactos e serve de reserva de energia — não é tecnicamente parte da pele, mas a conecta aos músculos e ossos subjacentes. É a faixa mais grossa, na base do bloco.", }, ); -// ---- 6 "colunas" de componentes, cada uma numa posição X própria, todas -// flutuando no mesmo plano frontal (FRONT_Y) — a altura (Z) de cada peça -// já indica em qual camada ela vive. ---- +// ---- 6 "colunas" de componentes, cada uma numa posição X própria, dentro +// do volume aberto do bloco — a altura (Z) de cada peça já indica em qual +// camada ela vive; um leve desvio em Y dá sensação de profundidade. ---- const COL = (i) => CX - 0.06 + i * 0.024; // 6 colunas igualmente espaçadas // ---- Coluna 1: complexo pilossebáceo (pelo + folículo + músculo eretor + glândula sebácea) ---- const HX = COL(0); +const HY = CY - 0.02; const HAIR_TILT = [Math.PI / 2 + 0.3, 0.2, 0]; add( "tegumentar", "pelo", "Pelo (Haste Capilar)", "Hair Shaft", [...TX, "Complexo Pilossebáceo"], - { type: "cylinder", rt: 0.0006, rb: 0.001, h: 0.02 }, [HX, FRONT_Y, Z_TOP + 0.01], + { type: "cylinder", rt: 0.0006, rb: 0.001, h: 0.02 }, [HX, HY, Z_TOP + 0.01], { color: "#4a3527", rotation: HAIR_TILT, @@ -160,7 +147,7 @@ add( ); add( "tegumentar", "foliculo-piloso", "Folículo Piloso", "Hair Follicle", [...TX, "Complexo Pilossebáceo"], - { type: "cylinder", rt: 0.0022, rb: 0.0022, h: 0.03 }, [HX, FRONT_Y, 0.023], + { type: "cylinder", rt: 0.0022, rb: 0.0022, h: 0.058 }, [HX, HY, 0.009], { color: "#8a6a52", rotation: HAIR_TILT, @@ -170,7 +157,7 @@ add( ); add( "tegumentar", "bulbo-piloso", "Bulbo Piloso", "Hair Bulb", [...TX, "Complexo Pilossebáceo"], - { type: "sphere", r: 0.003 }, [HX - 0.004, FRONT_Y, 0.008], + { type: "sphere", r: 0.004 }, [HX - 0.005, HY, -0.02], { color: "#6b4c38", description: @@ -179,7 +166,7 @@ add( ); add( "tegumentar", "musculo-eretor-do-pelo", "Músculo Eretor do Pelo", "Arrector Pili Muscle", [...TX, "Complexo Pilossebáceo"], - { type: "box", w: 0.0012, h: 0.014, d: 0.003 }, [HX + 0.004, FRONT_Y, 0.0285], + { type: "box", w: 0.0012, h: 0.014, d: 0.003 }, [HX + 0.005, HY, 0.017], { color: "#b5433a", rotation: [1.05, 0, 0], @@ -189,7 +176,7 @@ add( ); add( "tegumentar", "glandula-sebacea", "Glândula Sebácea", "Sebaceous Gland", [...TX, "Complexo Pilossebáceo"], - { type: "sphere", r: 0.004 }, [HX + 0.007, FRONT_Y, 0.031], + { type: "sphere", r: 0.005 }, [HX + 0.008, HY, 0.022], { color: "#f2c14e", description: @@ -197,11 +184,34 @@ add( }, ); +// ---- Fios de pelo extras, só decorativos (mesma descrição do "pelo"), pra +// a superfície do bloco não ficar com um pelo solitário — no desenho de +// referência vários pelos saem da epiderme ao mesmo tempo. ---- +const EXTRA_HAIRS = [ + [COL(1) + 0.01, CY - 0.03], + [COL(2) - 0.008, CY - 0.015], + [COL(4) + 0.006, CY - 0.025], +]; +EXTRA_HAIRS.forEach(([x, y], i) => { + add( + "tegumentar", `pelo-extra-${i + 1}`, "Pelo (Haste Capilar)", "Hair Shaft", + [...TX, "Complexo Pilossebáceo"], + { type: "cylinder", rt: 0.0006, rb: 0.001, h: 0.02 }, [x, y, Z_TOP + 0.01], + { + color: "#4a3527", + rotation: [Math.PI / 2 + (i - 1) * 0.15, 0.15, 0], + description: + "Porção do pelo visível acima da superfície da pele, formada por células queratinizadas mortas; sua cor vem da melanina produzida no bulbo piloso.", + }, + ); +}); + // ---- Coluna 2: glândula sudorípara (écrina) + ducto + poro ---- const SX = COL(1); +const SY = CY; add( "tegumentar", "glandula-sudoripara", "Glândula Sudorípara (Écrina)", "Eccrine Sweat Gland", [...TX, "Glândula Sudorípara"], - { type: "sphere", r: 0.005 }, [SX, FRONT_Y, 0.012], + { type: "sphere", r: 0.006 }, [SX, SY, -0.045], { color: "#4f7cff", description: @@ -210,7 +220,7 @@ add( ); add( "tegumentar", "ducto-sudoriparo", "Ducto Sudoríparo", "Sweat Duct", [...TX, "Glândula Sudorípara"], - { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.026 }, [SX, FRONT_Y, 0.025], + { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.083 }, [SX, SY, -0.0035], { color: "#7ba6ff", rotation: ROT_Z, @@ -220,7 +230,7 @@ add( ); add( "tegumentar", "poro-sudoriparo", "Poro Sudoríparo", "Sweat Pore", [...TX, "Glândula Sudorípara"], - { type: "sphere", r: 0.0012 }, [SX, FRONT_Y, Z_TOP + 0.0005], + { type: "sphere", r: 0.0012 }, [SX, SY, Z_TOP + 0.0005], { color: "#a9c6ff", description: "Abertura do ducto sudoríparo na superfície da epiderme, por onde o suor é liberado.", @@ -229,9 +239,10 @@ add( // ---- Coluna 3: plexo vascular dérmico ---- const VX = COL(2); +const VY = CY - 0.01; add( "tegumentar", "arteriola-dermica", "Arteríola Dérmica", "Dermal Arteriole", [...TX, "Plexo Vascular Dérmico"], - { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.01 }, [VX - 0.004, FRONT_Y, 0.026], + { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.02 }, [VX - 0.005, VY, 0.008], { color: "#c1121f", rotation: ROT_Z, @@ -240,7 +251,7 @@ add( ); add( "tegumentar", "venula-dermica", "Vênula Dérmica", "Dermal Venule", [...TX, "Plexo Vascular Dérmico"], - { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.01 }, [VX + 0.004, FRONT_Y, 0.026], + { type: "cylinder", rt: 0.0012, rb: 0.0012, h: 0.02 }, [VX + 0.005, VY, 0.008], { color: "#3457d5", rotation: ROT_Z, @@ -249,7 +260,7 @@ add( ); add( "tegumentar", "alca-capilar-dermica", "Alça Capilar Dérmica", "Dermal Capillary Loop", [...TX, "Plexo Vascular Dérmico"], - { type: "cylinder", rt: 0.0006, rb: 0.0006, h: 0.006 }, [VX, FRONT_Y, 0.033], + { type: "cylinder", rt: 0.0006, rb: 0.0006, h: 0.012 }, [VX, VY, 0.024], { color: "#e0464f", rotation: ROT_Z, @@ -260,7 +271,7 @@ add( // ---- Coluna 4: corpúsculo de Meissner (toque leve) ---- add( "tegumentar", "corpusculo-de-meissner", "Corpúsculo de Meissner", "Meissner's Corpuscle", [...TX, "Receptores Sensoriais"], - { type: "sphere", r: 0.0022 }, [COL(3), FRONT_Y, 0.033], + { type: "sphere", r: 0.003 }, [COL(3), CY - 0.015, 0.024], { color: "#34d399", description: @@ -271,7 +282,7 @@ add( // ---- Coluna 5: corpúsculo de Pacini (pressão profunda) ---- add( "tegumentar", "corpusculo-de-pacini", "Corpúsculo de Pacini", "Pacinian Corpuscle", [...TX, "Receptores Sensoriais"], - { type: "sphere", r: 0.006 }, [COL(4), FRONT_Y, 0.012], + { type: "sphere", r: 0.008 }, [COL(4), CY + 0.02, -0.045], { color: "#22d3ee", description: @@ -279,16 +290,18 @@ add( }, ); -// ---- Coluna 6: lóbulos de tecido adiposo da hipoderme ---- +// ---- Coluna 6: lóbulos de tecido adiposo, espalhados pela hipoderme ---- const FX = COL(5); +const FY = CY + 0.01; const FAT_OFFSETS = [ - [0, 0.013], [0.007, 0.017], [-0.006, 0.010], [0.003, 0.019], + [0, -0.02], [0.012, -0.03], [-0.01, -0.025], + [0.005, -0.04], [-0.008, -0.045], [0.01, -0.052], ]; FAT_OFFSETS.forEach(([dx, dz], i) => { add( "tegumentar", `adipocito-${i + 1}`, `Lóbulo de Tecido Adiposo ${i + 1}`, `Adipose Tissue Lobule ${i + 1}`, [...TX, "Tecido Adiposo"], - { type: "sphere", r: 0.006 }, [FX + dx, FRONT_Y, dz], + { type: "sphere", r: 0.007 }, [FX + dx, FY, dz], { color: "#f7e08c", description: diff --git a/frontend/src/components/QuickRegionNav.tsx b/frontend/src/components/QuickRegionNav.tsx index 7cfeddf..1f4a934 100644 --- a/frontend/src/components/QuickRegionNav.tsx +++ b/frontend/src/components/QuickRegionNav.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useAtlasStore, type BodyRegion } from "../store/useAtlasStore.js"; const REGIONS: { region: BodyRegion; label: string }[] = [ @@ -11,27 +12,121 @@ const REGIONS: { region: BodyRegion; label: string }[] = [ { region: "corpo_inteiro", label: "Corpo Inteiro" }, ]; +const STORAGE_KEY = "atlas.quickRegionNav.pos"; + +function loadStoredPos(): { x: number; y: number } | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + // Navegação rápida por região — em vez de precisar acertar o clique numa // estrutura minúscula pra girar/aproximar a câmera manualmente, o usuário -// pede direto pra "ir" numa região do corpo. +// pede direto pra "ir" numa região do corpo. Arrastável pela alcinha (⠿), +// pois a barra pode cair em cima de uma parte do modelo que o usuário +// queira ver — a posição escolhida fica salva entre visitas. export function QuickRegionNav() { const focusOnRegion = useAtlasStore((s) => s.focusOnRegion); + const [pos, setPos] = useState<{ x: number; y: number } | null>(() => loadStoredPos()); + const elRef = useRef(null); + const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + + function clamp(x: number, y: number) { + const el = elRef.current; + const parent = el?.offsetParent as HTMLElement | null; + if (!el || !parent) return { x, y }; + const maxX = Math.max(0, parent.clientWidth - el.offsetWidth); + const maxY = Math.max(0, parent.clientHeight - el.offsetHeight); + return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }; + } + + function onGripPointerDown(e: ReactPointerEvent) { + const el = elRef.current; + const parent = el?.offsetParent as HTMLElement | null; + if (!el || !parent) return; + // Antes do primeiro arrasto a barra fica centralizada via CSS transform + // (translate-x-1/2) — offsetLeft/offsetTop ignoram transform, então + // calculamos a posição visual real a partir do bounding rect pra não + // dar aquele "pulo" no primeiro clique. + const elRect = el.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + dragRef.current = { + startX: e.clientX, + startY: e.clientY, + origX: elRect.left - parentRect.left, + origY: elRect.top - parentRect.top, + }; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + } + + function onGripPointerMove(e: ReactPointerEvent) { + if (!dragRef.current) return; + const dx = e.clientX - dragRef.current.startX; + const dy = e.clientY - dragRef.current.startY; + setPos(clamp(dragRef.current.origX + dx, dragRef.current.origY + dy)); + } + + function onGripPointerUp() { + dragRef.current = null; + setPos((current) => { + if (current) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(current)); + } catch { + // ignore + } + } + return current; + }); + } + + // Se a janela mudar de tamanho (ex: girar o celular), reaplica o clamp + // pra barra não ficar presa fora da área visível. + useEffect(() => { + if (!pos) return; + function onResize() { + setPos((current) => (current ? clamp(current.x, current.y) : current)); + } + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
- {REGIONS.map((r) => ( - - ))} +
+ ⠿ +
+
+ {REGIONS.map((r) => ( + + ))} +
); }