Vista Explodida
Desenho em vista explodida de um conjunto de peças cilíndricas (disco, anel, eixo, cilindro) em SVG puro, com chamadas de letra e linhas guia. A separação anima ao aparecer ou é controlada; destaque e descrição de cada peça ao passar o mouse.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<VistaExplodida>
Vista explodida de um conjunto de peças cilíndricas, em SVG puro: cada peça é desenhada em isométrico ao longo do eixo de montagem, com chamadas de letra e linhas guia. A separação anima de montado a explodido ao entrar na tela (ou fica fixa com `separacao`). Passar o mouse numa peça ou na legenda destaca ela e mostra o nome e a descrição.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| pecasobrigatória | PecaExplodida[] | — | |
| rotuloobrigatória | string | — | Descrição curta para leitores de tela ("Conjunto da roda com eixo e espaçadores"). |
| separacao | number | — | Separação entre as peças, de 0 (montado) a 1 (explodido). Sem valor, anima de montado a explodido quando aparece na tela. |
| angulo | number | 28 | Ângulo do eixo de montagem, em graus a partir da horizontal (positivo desce para a direita). |
| mostrarLegenda | boolean | true | Mostra a lista de peças abaixo do desenho. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useRef, useState } from "react";
import { useEmTela, useSemMovimento } from "@/library/revelar/Movimento";
/**
* Uma peça do conjunto, montada ao longo do eixo na ordem da lista (a primeira fica no fundo).
* Medidas relativas: `raio` de 0 a 1 (1 = a peça mais larga) e `comprimento` na mesma escala.
*/
export type PecaExplodida = {
/** Letra ou número da chamada ("A", "1"). Vazio: a peça não ganha chamada nem entra na legenda. */
marcador: string;
nome: string;
descricao?: string;
/** disco: cilindro curto · anel: disco com furo · eixo: cilindro fino e longo · cilindro: corpo com furo raso na frente. */
forma: "disco" | "anel" | "eixo" | "cilindro";
raio: number;
comprimento: number;
cor?: string;
/** Raio do furo do anel ou do cilindro, em fração do raio da peça. */
furo?: number;
/** Fica grudada na peça anterior mesmo explodida (um eixo que faz parte do cubo, por exemplo). */
presaAnterior?: boolean;
};
export type VistaExplodidaProps = {
pecas: PecaExplodida[];
/** Descrição curta para leitores de tela ("Conjunto da roda com eixo e espaçadores"). */
rotulo: string;
/**
* Separação entre as peças, de 0 (montado) a 1 (explodido). Sem valor, anima de montado a
* explodido quando aparece na tela.
*/
separacao?: number;
/** Ângulo do eixo de montagem, em graus a partir da horizontal (positivo desce para a direita). */
angulo?: number;
/** Mostra a lista de peças abaixo do desenho. */
mostrarLegenda?: boolean;
};
const LARGURA = 480;
const ALTURA = 300;
const ACHATAMENTO = 0.42;
const FOLGA = 0.6;
type Vetor = [number, number];
// Duas casas: servidor e navegador podem diferir na última casa de seno e cosseno (erro de hidratação)
const arredondar = (n: number) => Math.round(n * 100) / 100;
const somar = (a: Vetor, b: Vetor, k = 1): Vetor => [arredondar(a[0] + b[0] * k), arredondar(a[1] + b[1] * k)];
/** Elipse da face de uma peça: eixo maior perpendicular ao eixo de montagem. */
function elipse(c: Vetor, r: number, u: Vetor, n: Vetor) {
const pontos = Array.from({ length: 33 }, (_, k) => {
const t = (k / 32) * Math.PI * 2;
return somar(
somar(c, n, r * Math.cos(t)),
u,
r * ACHATAMENTO * Math.sin(t),
);
});
return `M${pontos.map((p) => p.join(",")).join("L")}Z`;
}
/**
* Vista explodida de um conjunto de peças cilíndricas, em SVG puro: cada peça é desenhada em
* isométrico ao longo do eixo de montagem, com chamadas de letra e linhas guia. A separação anima
* de montado a explodido ao entrar na tela (ou fica fixa com `separacao`). Passar o mouse numa peça
* ou na legenda destaca ela e mostra o nome e a descrição.
*/
export function VistaExplodida({
pecas,
rotulo,
separacao,
angulo = 28,
mostrarLegenda = true,
}: VistaExplodidaProps) {
const ref = useRef<SVGSVGElement>(null);
const semMovimento = useSemMovimento();
const visivel = useEmTela(ref);
const [progresso, setProgresso] = useState(0);
const [ativa, setAtiva] = useState<number | null>(null);
// Sem `separacao`, anima de 0 a 1 uma vez, quando aparece
useEffect(() => {
if (separacao !== undefined || !visivel || semMovimento) return;
let quadro = 0;
const inicio = performance.now();
const passo = (agora: number) => {
const t = Math.min(1, (agora - inicio) / 1100);
setProgresso(1 - (1 - t) ** 3);
if (t < 1) quadro = requestAnimationFrame(passo);
};
quadro = requestAnimationFrame(passo);
return () => cancelAnimationFrame(quadro);
}, [visivel, semMovimento, separacao]);
const s = separacao ?? (semMovimento ? 1 : progresso);
const a = (angulo * Math.PI) / 180;
const u: Vetor = [Math.cos(a), Math.sin(a)];
const n: Vetor = [-Math.sin(a), Math.cos(a)];
// Comprimento total no eixo, com as folgas da explosão, para encaixar no quadro
const raioMax = Math.max(...pecas.map((p) => p.raio), 0.01);
const folgas = pecas.slice(1).filter((p) => !p.presaAnterior).length;
const total =
pecas.reduce((t, p) => t + p.comprimento, 0) + FOLGA * s * folgas;
const alcanceX = total * Math.abs(u[0]) + 2 * raioMax * Math.abs(n[0]);
const alcanceY = total * Math.abs(u[1]) + 2 * raioMax * Math.abs(n[1]);
const escala = Math.min((LARGURA - 120) / alcanceX, (ALTURA - 90) / alcanceY);
const inicio: Vetor = [
LARGURA / 2 - (u[0] * total * escala) / 2,
ALTURA / 2 - (u[1] * total * escala) / 2,
];
// Início de cada peça no eixo: soma dos comprimentos anteriores e das folgas entre peças soltas
const inicios = pecas.reduce<number[]>((lista, p, i) => {
if (i === 0) return [0];
const anterior = lista[i - 1] + pecas[i - 1].comprimento + (p.presaAnterior ? 0 : FOLGA * s);
return [...lista, anterior];
}, []);
const desenhos = pecas.map((p, i) => {
const c0 = somar(inicio, u, inicios[i] * escala);
const c1 = somar(inicio, u, (inicios[i] + p.comprimento) * escala);
const r = p.raio * escala;
const cor = p.cor ?? "var(--series-1)";
// Chamadas alternam acima e abaixo do eixo, a partir da borda da peça
const lado = i % 2 === 0 ? 1 : -1;
const meio = somar(c0, u, (p.comprimento * escala) / 2);
const borda = somar(meio, n, r * lado);
const ponta = somar(borda, n, (raioMax * escala - r + 34) * lado);
return { p, i, c0, c1, r, cor, borda, ponta, lado };
});
const sombra = (cor: string, k: number) =>
`color-mix(in oklab, ${cor} ${100 - k}%, black)`;
return (
<figure className="m-0 flex w-full max-w-xl flex-col gap-3">
<div className="relative">
<svg
ref={ref}
viewBox={`0 0 ${LARGURA} ${ALTURA}`}
className="block w-full select-none"
role="img"
aria-label={`${rotulo}. ${pecas.map((p) => `${p.marcador}: ${p.nome}`).join(". ")}.`}
>
{/* Linha do eixo de montagem */}
<line
x1={somar(inicio, u, -0.3 * escala)[0]}
y1={somar(inicio, u, -0.3 * escala)[1]}
x2={somar(inicio, u, (total + 0.3) * escala)[0]}
y2={somar(inicio, u, (total + 0.3) * escala)[1]}
stroke="var(--axis)"
strokeWidth={1}
strokeDasharray="6 4"
opacity={s}
/>
{desenhos.map(({ p, i, c0, c1, r, cor }) => {
const apagada = ativa !== null && ativa !== i;
const furo =
p.forma === "anel"
? (p.furo ?? 0.6)
: p.forma === "cilindro"
? (p.furo ?? 0.35)
: 0;
const corpo = [
somar(c0, n, r),
somar(c1, n, r),
somar(c1, n, -r),
somar(c0, n, -r),
];
return (
<g
key={i}
opacity={apagada ? 0.3 : 1}
className="transition-opacity duration-150"
onPointerEnter={() => setAtiva(i)}
onPointerLeave={() => setAtiva(null)}
>
{/* Fundo, corpo e frente: a face da frente fica virada para baixo e para a direita */}
<path d={elipse(c0, r, u, n)} fill={sombra(cor, 30)} />
<path
d={`M${corpo.map((q) => q.join(",")).join("L")}Z`}
fill={sombra(cor, 18)}
/>
<path
d={elipse(c1, r, u, n)}
fill={cor}
stroke={sombra(cor, 30)}
strokeWidth={0.75}
/>
{furo > 0 && (
<>
<path
d={elipse(c1, r * furo, u, n)}
fill={sombra(cor, 42)}
/>
{/* Brilho na borda interna do furo, do lado oposto à luz */}
<path
d={elipse(
somar(c1, u, r * furo * 0.25),
r * furo * 0.82,
u,
n,
)}
fill={sombra(cor, 55)}
opacity={p.forma === "anel" ? 0 : 0.6}
/>
</>
)}
{p.forma === "disco" && (
<path
d={elipse(c1, r * 0.55, u, n)}
fill="none"
stroke={sombra(cor, 18)}
strokeWidth={1}
/>
)}
</g>
);
})}
{desenhos
.filter(({ p }) => p.marcador)
.map(({ p, i, borda, ponta, lado }) => (
<g
key={`chamada-${i}`}
opacity={0.4 + 0.6 * s}
pointerEvents="none"
>
<line
x1={borda[0]}
y1={borda[1]}
x2={ponta[0]}
y2={ponta[1]}
stroke="var(--axis)"
strokeWidth={1}
/>
<circle cx={borda[0]} cy={borda[1]} r={2} fill="var(--ink)" />
<text
x={ponta[0]}
y={ponta[1] + (lado > 0 ? 12 : -6)}
textAnchor="middle"
className={`text-xs font-bold ${ativa === i ? "fill-accent" : "fill-ink"}`}
>
{p.marcador}
</text>
</g>
))}
</svg>
{ativa !== null && (
<div
aria-hidden="true"
className="pointer-events-none absolute top-2 left-2 z-10 max-w-60 rounded-lg border border-rule bg-page px-3 py-2 text-sm shadow-flutuante"
>
<div className="font-semibold">
{pecas[ativa].marcador} · {pecas[ativa].nome}
</div>
{pecas[ativa].descricao && (
<div className="mt-0.5 text-ink-2">{pecas[ativa].descricao}</div>
)}
</div>
)}
</div>
{mostrarLegenda && (
<ol className="grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
{pecas.map(
(p, i) =>
p.marcador && (
<li
key={i}
className={`flex items-baseline gap-2 rounded-sm transition-colors ${ativa === i ? "text-ink" : "text-ink-2"}`}
onPointerEnter={() => setAtiva(i)}
onPointerLeave={() => setAtiva(null)}
>
<span className="inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-ink/10 text-xs font-bold text-ink">
{p.marcador}
</span>
<span>
<span className="font-medium text-ink">{p.nome}</span>
{p.descricao && <span> · {p.descricao}</span>}
</span>
</li>
),
)}
</ol>
)}
</figure>
);
}
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { ControleDeslizante } from "@/library/controle-deslizante/ControleDeslizante";
import { VistaExplodida, type PecaExplodida } from "./VistaExplodida";
const RODA: PecaExplodida[] = [
{ marcador: "A", nome: "Cubo com eixo", descricao: "Flange que prende na estrutura", forma: "disco", raio: 1, comprimento: 0.45 },
{ marcador: "", nome: "Eixo do cubo", forma: "eixo", raio: 0.24, comprimento: 1.1, presaAnterior: true },
{ marcador: "B", nome: "Arruela", descricao: "Distribui a carga no rolamento", forma: "anel", raio: 0.42, comprimento: 0.12, furo: 0.62 },
{ marcador: "C", nome: "Espaçador", forma: "anel", raio: 0.42, comprimento: 0.12, furo: 0.62 },
{ marcador: "D", nome: "Roda", descricao: "Corpo de borracha com furo para o eixo", forma: "cilindro", raio: 0.82, comprimento: 0.7, furo: 0.32 },
];
const FILTRO: PecaExplodida[] = [
{ marcador: "1", nome: "Tampa", forma: "disco", raio: 0.9, comprimento: 0.25, cor: "var(--series-2)" },
{ marcador: "2", nome: "Vedação", descricao: "Anel de borracha", forma: "anel", raio: 0.8, comprimento: 0.08, furo: 0.8, cor: "var(--series-4)" },
{ marcador: "3", nome: "Elemento filtrante", descricao: "Troque a cada 6 meses", forma: "cilindro", raio: 0.75, comprimento: 1.2, furo: 0.45, cor: "var(--series-3)" },
{ marcador: "4", nome: "Copo", forma: "cilindro", raio: 1, comprimento: 1.4, furo: 0.85, cor: "var(--series-2)" },
];
function ExemploComControle() {
const [separacao, setSeparacao] = useState(0.5);
return (
<div className="flex w-full max-w-xl flex-col gap-4">
<VistaExplodida pecas={FILTRO} rotulo="Filtro de água desmontado" separacao={separacao} angulo={20} />
<ControleDeslizante
rotulo="Separação"
valor={separacao * 100}
aoMudar={(v) => setSeparacao((v as number) / 100)}
formatar={(n) => (n === 0 ? "Montado" : n === 100 ? "Explodido" : `${n}%`)}
/>
</div>
);
}
export default defineStories({
args: { angulo: 28, mostrarLegenda: true, animar: true, separacao: 1 },
controls: {
angulo: { type: "number", min: 0, max: 60, step: 2 },
mostrarLegenda: { type: "boolean" },
animar: { type: "boolean", label: "animar ao aparecer (ignora separacao)" },
separacao: { type: "number", min: 0, max: 1, step: 0.05 },
},
render: (a) => (
<VistaExplodida
pecas={RODA}
rotulo="Conjunto da roda com eixo, arruela e espaçador"
angulo={a.angulo as number}
mostrarLegenda={a.mostrarLegenda as boolean}
separacao={a.animar ? undefined : (a.separacao as number)}
/>
),
stories: [
{ name: "Roda" },
{ name: "Separação controlada", render: () => <ExemploComControle /> },
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Componentes da biblioteca (diretos e indiretos)
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}