时间:2026-09-16浏览:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#081012">
<meta name="description" content="中压块组成部分的 360° 结构展示与自动组装演示。">
<title>中压块 · 结构组成</title>
<style>
/* =====================================================================
Design system
- One warm metal accent keeps the dark industrial language restrained.
- The scene itself is sized as a 16:9 stage; the page letterboxes cleanly
when the browser ratio is wider or taller.
===================================================================== */
:root {
color-scheme: dark;
--stage-bg: #081012;
--stage-bg-deep: #05090b;
--surface: rgba(16, 27, 30, 0.72);
--surface-strong: rgba(12, 21, 24, 0.9);
--line: rgba(174, 192, 185, 0.3);
--line-soft: rgba(174, 192, 185, 0.14);
--line-faint: rgba(174, 192, 185, 0.075);
--text: #edf0eb;
--text-muted: #96a6a1;
--text-dim: #657572;
--steel: #9fbab3;
--accent: #d6a85e;
--accent-bright: #f1ca7f;
--accent-soft: rgba(214, 168, 94, 0.22);
--shadow: rgba(0, 0, 0, 0.5);
--ease-out: cubic-bezier(0.22, 0.8, 0.25, 1);
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
min-height: 100%;
margin: 0;
}
body {
overflow: hidden;
display: grid;
place-items: center;
color: var(--text);
background:
radial-gradient(circle at 50% 44%, rgba(27, 52, 53, 0.18), transparent 52%),
var(--stage-bg-deep);
font-family: "Avenir Next", "Noto Sans SC", "PingFang SC", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
}
button,
img {
-webkit-user-drag: none;
}
button {
font: inherit;
}
.stage-shell {
position: relative;
width: min(100vw, 177.7778vh);
height: min(100vh, 56.25vw);
overflow: hidden;
isolation: isolate;
background: var(--stage-bg);
container-type: size;
}
/* Background is intentionally quieter than the foreground product. */
.stage-shell::before,
.stage-shell::after {
position: absolute;
inset: 0;
z-index: -3;
pointer-events: none;
content: "";
}
.stage-shell::before {
opacity: 0.38;
background-image:
radial-gradient(circle, rgba(167, 192, 184, 0.28) 0.7px, transparent 0.9px),
radial-gradient(circle at 70% 25%, rgba(213, 168, 94, 0.12), transparent 24%);
background-position: 0 0, center;
background-size: 17px 17px, 100% 100%;
mask-image: linear-gradient(to bottom, transparent 0%, black 14%, black 78%, transparent 100%);
}
.stage-shell::after {
opacity: 0.18;
background-image:
linear-gradient(rgba(159, 186, 179, 0.2) 1px, transparent 1px),
linear-gradient(90deg, rgba(159, 186, 179, 0.2) 1px, transparent 1px);
background-position: center;
background-size: 72px 72px;
mask-image: radial-gradient(ellipse at center, black 0%, transparent 72%);
}
.ambient-orbit {
position: absolute;
inset: 11% 12% 10%;
z-index: -2;
border: 1px solid rgba(159, 186, 179, 0.045);
border-radius: 50%;
transform: rotate(-10deg) scaleX(1.28);
pointer-events: none;
}
.ambient-orbit::before,
.ambient-orbit::after {
position: absolute;
inset: 7%;
border: 1px solid rgba(159, 186, 179, 0.035);
border-radius: 50%;
content: "";
}
.ambient-orbit::after {
inset: 18%;
border-color: rgba(214, 168, 94, 0.035);
}
.crosshair {
position: absolute;
z-index: -1;
width: 28px;
height: 28px;
opacity: 0.42;
pointer-events: none;
}
.crosshair::before,
.crosshair::after {
position: absolute;
top: 50%;
left: 50%;
background: var(--line);
content: "";
transform: translate(-50%, -50%);
}
.crosshair::before {
width: 1px;
height: 100%;
}
.crosshair::after {
width: 100%;
height: 1px;
}
.crosshair span {
position: absolute;
inset: 8px;
border: 1px solid var(--line);
border-radius: 50%;
}
.crosshair-a { top: 20%; left: 8%; }
.crosshair-b { top: 17%; right: 10%; transform: scale(0.7); }
.crosshair-c { bottom: 16%; left: 15%; transform: scale(0.56); }
.crosshair-d { right: 7%; bottom: 20%; transform: scale(0.84); }
.page-header {
position: absolute;
top: clamp(22px, 4.5cqw, 68px);
right: clamp(28px, 5.2cqw, 84px);
left: clamp(28px, 5.2cqw, 84px);
z-index: 10;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 32px;
}
.title-lockup {
display: grid;
gap: 9px;
}
.title-kicker,
.header-meta,
.status-line,
.metric-label,
.part-en,
.readout-index,
.core-en,
.assembly-kicker {
font-family: "SFMono-Regular", "Roboto Mono", "Liberation Mono", ui-monospace, monospace;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.title-kicker {
display: flex;
align-items: center;
gap: 10px;
color: var(--accent);
font-size: clamp(8px, 0.68cqw, 12px);
line-height: 1;
}
.title-kicker::before {
display: block;
width: 26px;
height: 1px;
background: var(--accent);
content: "";
}
h1 {
margin: 0;
color: var(--text);
font-size: clamp(25px, 3.15cqw, 56px);
font-weight: 500;
letter-spacing: -0.045em;
line-height: 0.98;
text-wrap: pretty;
}
.subtitle {
margin: 0;
color: var(--text-muted);
font-family: "SFMono-Regular", "Roboto Mono", "Liberation Mono", ui-monospace, monospace;
font-size: clamp(9px, 0.78cqw, 13px);
letter-spacing: 0.26em;
line-height: 1;
}
.header-meta {
display: grid;
justify-items: end;
gap: 10px;
padding-top: 4px;
color: var(--text-dim);
font-size: clamp(7px, 0.62cqw, 11px);
line-height: 1.2;
text-align: right;
}
.header-meta strong {
color: var(--steel);
font-weight: 500;
}
.header-meta .meta-rule {
width: clamp(70px, 8cqw, 128px);
height: 1px;
background: linear-gradient(90deg, transparent, var(--line));
}
/* Main visual field. JS uses this element's actual dimensions for the
orbit equations, so the same math survives any 16:9-sized viewport. */
.visual-stage {
position: absolute;
inset: 16.5% 4.9% 16.2%;
z-index: 1;
overflow: visible;
}
.orbit-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.orbit-svg circle,
.orbit-svg line {
vector-effect: non-scaling-stroke;
}
.orbit-outer {
fill: none;
stroke: rgba(170, 191, 183, 0.42);
stroke-dasharray: 2 11;
stroke-linecap: round;
}
.orbit-inner {
fill: none;
stroke: rgba(159, 186, 179, 0.14);
stroke-dasharray: 1 4;
}
.orbit-inner-glow {
fill: none;
stroke: rgba(214, 168, 94, 0.08);
stroke-dasharray: 44 190;
stroke-linecap: round;
}
.orbit-tick {
stroke: rgba(159, 186, 179, 0.24);
stroke-width: 1;
}
.orbit-tick.major {
stroke: rgba(214, 168, 94, 0.48);
}
.connector-glow,
.connector-line {
fill: none;
opacity: 0;
stroke-linecap: round;
transition: opacity 0.26s var(--ease-soft);
}
.connector-glow {
stroke: rgba(214, 168, 94, 0.22);
stroke-width: 9;
filter: blur(6px);
}
.connector-line {
stroke: var(--accent-bright);
stroke-width: 1.5;
stroke-dasharray: 4 7;
}
.has-focus .connector-glow,
.has-focus .connector-line {
opacity: 1;
}
.has-focus .connector-line {
animation: connector-flow 1.05s linear infinite;
}
.connector-dot {
opacity: 0;
fill: var(--accent-bright);
stroke: var(--stage-bg);
stroke-width: 4;
transition: opacity 0.26s var(--ease-soft);
}
.has-focus .connector-dot {
opacity: 1;
}
@keyframes connector-flow {
to { stroke-dashoffset: -22; }
}
.central-product {
position: absolute;
z-index: 5;
width: clamp(170px, 16vw, 300px);
aspect-ratio: 1;
pointer-events: none;
transform: translate(-50%, -50%);
}
.product-halo,
.product-halo::before,
.product-halo::after {
position: absolute;
inset: 0;
border-radius: 50%;
content: "";
}
.product-halo {
background: radial-gradient(circle, rgba(100, 137, 132, 0.16) 0%, rgba(49, 74, 73, 0.08) 38%, transparent 70%);
filter: blur(1px);
animation: halo-breathe 4.8s ease-in-out infinite;
}
.product-halo::before {
inset: 13%;
border: 1px solid rgba(164, 193, 185, 0.18);
border-right-color: rgba(214, 168, 94, 0.6);
border-bottom-color: transparent;
transform: rotate(-28deg);
}
.product-halo::after {
inset: 22%;
border: 1px solid rgba(164, 193, 185, 0.09);
border-left-color: rgba(214, 168, 94, 0.36);
transform: rotate(32deg);
}
.product-asset-wrap {
position: absolute;
inset: 12%;
display: grid;
place-items: center;
}
.product-asset {
width: 86%;
height: 86%;
object-fit: contain;
filter: drop-shadow(0 22px 22px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 16px rgba(181, 208, 200, 0.16));
animation: product-float 5.6s ease-in-out infinite;
}
.product-sheen {
position: absolute;
inset: 8%;
overflow: hidden;
border-radius: 50%;
opacity: 0.5;
mix-blend-mode: screen;
pointer-events: none;
}
.product-sheen::after {
position: absolute;
top: -30%;
left: -45%;
width: 28%;
height: 160%;
background: linear-gradient(90deg, transparent, rgba(248, 227, 177, 0.45), transparent);
content: "";
filter: blur(8px);
transform: rotate(25deg);
animation: metal-sheen 6.8s ease-in-out infinite;
}
.core-caption {
position: absolute;
top: 92%;
left: 50%;
display: grid;
gap: 5px;
justify-items: center;
width: max-content;
transform: translateX(-50%);
transition: opacity 0.3s var(--ease-soft);
}
.core-name {
color: var(--text);
font-size: clamp(13px, 1.05cqw, 18px);
font-weight: 500;
letter-spacing: 0.08em;
white-space: nowrap;
}
.core-en {
color: var(--accent);
font-size: clamp(7px, 0.55cqw, 10px);
white-space: nowrap;
}
.central-product.is-assembly .product-halo {
background: radial-gradient(circle, rgba(214, 168, 94, 0.3) 0%, rgba(111, 88, 53, 0.1) 42%, transparent 72%);
animation: assembly-pulse 1.1s ease-in-out infinite alternate;
}
.central-product.is-assembly .product-halo::before {
border-color: rgba(239, 203, 132, 0.7);
border-left-color: transparent;
animation: halo-spin 2.4s linear infinite;
}
.central-product.is-assembly .product-asset {
filter: drop-shadow(0 24px 25px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 22px rgba(240, 196, 112, 0.48));
}
.central-product.is-assembly .core-caption {
opacity: 0;
}
.part-node {
position: absolute;
z-index: 6;
display: grid;
justify-items: center;
width: clamp(112px, 11cqw, 190px);
padding: 0;
color: var(--text);
background: transparent;
border: 0;
cursor: pointer;
opacity: 1;
outline: none;
transform: translate(-50%, -50%) scale(var(--node-scale, 1));
transform-origin: center center;
user-select: none;
will-change: left, top, opacity, transform;
}
.part-node:focus-visible .part-media {
outline: 1px solid var(--accent-bright);
outline-offset: 6px;
}
.part-media {
position: relative;
display: grid;
place-items: center;
width: clamp(64px, 7.1cqw, 124px);
height: clamp(64px, 7.1cqw, 124px);
overflow: visible;
border: 1px solid rgba(169, 191, 184, 0.31);
border-radius: 50%;
background:
radial-gradient(circle at 40% 26%, rgba(142, 179, 169, 0.13), transparent 49%),
rgba(11, 22, 24, 0.74);
box-shadow: inset 0 0 18px rgba(183, 210, 198, 0.035), 0 11px 25px rgba(0, 0, 0, 0.18);
transition: border-color 0.3s var(--ease-soft), background 0.3s var(--ease-soft), box-shadow 0.3s var(--ease-soft), transform 0.3s var(--ease-out);
}
.part-media::before {
position: absolute;
inset: 7px;
border: 1px solid rgba(159, 186, 179, 0.08);
border-radius: 50%;
content: "";
}
.part-media::after {
position: absolute;
right: 11%;
bottom: 9%;
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(214, 168, 94, 0.7);
box-shadow: 0 0 0 4px rgba(214, 168, 94, 0.07);
content: "";
}
.part-image {
position: relative;
z-index: 1;
display: block;
width: 76%;
height: 76%;
object-fit: contain;
filter: drop-shadow(0 10px 10px rgba(0, 0, 0, 0.44));
transition: filter 0.3s var(--ease-soft), transform 0.3s var(--ease-out);
}
.part-node[data-kind="washer"] .part-image,
.part-node[data-kind="nut"] .part-image {
width: 58%;
height: 58%;
}
.part-node[data-kind="plate"] .part-image {
width: 82%;
height: 74%;
}
.part-copy {
display: grid;
gap: 4px;
margin-top: 11px;
justify-items: center;
min-width: 126px;
text-align: center;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.55);
}
.part-name {
font-size: clamp(10px, 0.88cqw, 15px);
font-weight: 500;
letter-spacing: 0.06em;
line-height: 1.1;
white-space: nowrap;
}
.part-en {
color: var(--text-dim);
font-size: clamp(6px, 0.48cqw, 9px);
line-height: 1;
white-space: nowrap;
}
.part-keyword {
color: var(--accent);
font-size: clamp(8px, 0.6cqw, 11px);
letter-spacing: 0.12em;
line-height: 1;
white-space: nowrap;
}
.part-node:hover .part-media,
.part-node.is-hovered .part-media {
border-color: rgba(241, 202, 127, 0.82);
background:
radial-gradient(circle at 40% 26%, rgba(214, 168, 94, 0.26), transparent 49%),
rgba(20, 29, 27, 0.88);
box-shadow: inset 0 0 22px rgba(214, 168, 94, 0.08), 0 12px 28px rgba(0, 0, 0, 0.25), 0 0 22px rgba(214, 168, 94, 0.12);
transform: scale(1.08);
}
.part-node.is-click-highlight .part-media {
border-color: var(--accent-bright);
box-shadow: inset 0 0 22px rgba(214, 168, 94, 0.11), 0 12px 30px rgba(0, 0, 0, 0.28), 0 0 28px rgba(214, 168, 94, 0.22);
animation: click-highlight 1s var(--ease-out) both;
}
.part-node.is-click-highlight .part-media::after {
animation: click-ping 1s var(--ease-out) both;
}
.part-node:hover .part-image,
.part-node.is-hovered .part-image {
filter: drop-shadow(0 12px 11px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(241, 202, 127, 0.18));
}
.part-node.is-dimmed {
opacity: 0.35;
}
.part-node.is-dimmed .part-media {
filter: saturate(0.55);
}
.part-node.is-assembly {
pointer-events: none;
}
.part-node.is-missing .part-media::before {
border-style: dashed;
border-color: rgba(214, 168, 94, 0.44);
}
.part-node.is-missing .part-media::after {
display: none;
}
.part-node.is-missing .part-media::before {
display: grid;
place-items: center;
color: var(--accent);
font-family: "SFMono-Regular", "Roboto Mono", ui-monospace, monospace;
font-size: 8px;
letter-spacing: 0.1em;
content: "IMAGE";
}
.focus-readout {
position: absolute;
top: 4%;
right: 1.1%;
z-index: 8;
display: grid;
gap: 6px;
min-width: clamp(150px, 14cqw, 240px);
padding: 3px 0 3px 14px;
border-left: 1px solid var(--line);
opacity: 0.64;
transition: opacity 0.3s var(--ease-soft), border-color 0.3s var(--ease-soft);
}
.focus-readout.has-focus {
border-left-color: var(--accent);
opacity: 1;
}
.readout-index {
color: var(--accent);
font-size: clamp(7px, 0.54cqw, 10px);
line-height: 1;
}
.readout-name {
color: var(--text);
font-size: clamp(15px, 1.28cqw, 22px);
font-weight: 500;
letter-spacing: 0.08em;
line-height: 1;
text-wrap: pretty;
}
.readout-en {
color: var(--text-muted);
font-family: "SFMono-Regular", "Roboto Mono", ui-monospace, monospace;
font-size: clamp(8px, 0.62cqw, 11px);
letter-spacing: 0.12em;
line-height: 1;
text-transform: uppercase;
}
.readout-keyword {
color: var(--accent-bright);
font-size: clamp(9px, 0.68cqw, 12px);
letter-spacing: 0.14em;
line-height: 1;
}
.readout-hint {
color: var(--text-dim);
font-family: "SFMono-Regular", "Roboto Mono", ui-monospace, monospace;
font-size: clamp(6px, 0.48cqw, 9px);
letter-spacing: 0.1em;
line-height: 1;
}
.focus-readout.has-focus .readout-hint {
display: none;
}
.assembly-badge {
position: absolute;
z-index: 9;
display: grid;
gap: 5px;
justify-items: center;
min-width: 188px;
padding: 10px 18px 11px;
border: 1px solid rgba(214, 168, 94, 0.46);
background: rgba(10, 17, 18, 0.77);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.24), inset 0 0 22px rgba(214, 168, 94, 0.05);
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) translateY(7px) scale(0.96);
transition: opacity 0.4s var(--ease-out), transform 0.5s var(--ease-out);
}
.assembly-badge.is-visible {
opacity: 1;
transform: translate(-50%, -50%) translateY(0) scale(1);
}
.assembly-kicker {
color: var(--accent);
font-size: clamp(7px, 0.55cqw, 10px);
line-height: 1;
}
.assembly-title {
color: var(--text);
font-family: "SFMono-Regular", "Roboto Mono", ui-monospace, monospace;
font-size: clamp(9px, 0.68cqw, 12px);
letter-spacing: 0.13em;
line-height: 1;
text-transform: uppercase;
}
.assembly-cn {
color: var(--text-muted);
font-size: clamp(10px, 0.74cqw, 13px);
letter-spacing: 0.12em;
line-height: 1;
}
.page-footer {
position: absolute;
right: clamp(28px, 5.2cqw, 84px);
bottom: clamp(22px, 4.2cqw, 64px);
left: clamp(28px, 5.2cqw, 84px);
z-index: 10;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 26px;
}
.metric-row {
display: flex;
align-items: stretch;
min-width: min(62%, 700px);
}
.metric {
display: grid;
gap: 6px;
min-width: 155px;
padding: 0 clamp(15px, 1.6cqw, 27px);
border-left: 1px solid var(--line-soft);
}
.metric:first-child {
padding-left: 0;
border-left: 0;
}
.metric-value {
color: var(--text);
font-family: "SFMono-Regular", "Roboto Mono", "Liberation Mono", ui-monospace, monospace;
font-size: clamp(12px, 1.03cqw, 17px);
letter-spacing: 0.12em;
line-height: 1;
}
.metric-label {
color: var(--text-muted);
font-size: clamp(7px, 0.54cqw, 10px);
line-height: 1.35;
white-space: nowrap;
}
.metric-cn {
color: var(--text-dim);
font-size: clamp(8px, 0.6cqw, 11px);
line-height: 1;
white-space: nowrap;
}
.status-line {
display: flex;
align-items: center;
gap: 9px;
padding-bottom: 2px;
color: var(--text-dim);
font-size: clamp(7px, 0.56cqw, 10px);
white-space: nowrap;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px rgba(214, 168, 94, 0.08), 0 0 14px rgba(214, 168, 94, 0.28);
animation: status-blink 2.4s ease-in-out infinite;
}
.status-line.is-paused .status-dot {
background: var(--text-dim);
box-shadow: 0 0 0 4px rgba(159, 186, 179, 0.06);
animation: none;
}
@keyframes halo-breathe {
0%, 100% { opacity: 0.7; transform: scale(0.97); }
50% { opacity: 1; transform: scale(1.045); }
}
@keyframes product-float {
0%, 100% { transform: translate3d(0, 3px, 0); }
50% { transform: translate3d(0, -5px, 0); }
}
@keyframes metal-sheen {
0%, 22% { left: -45%; opacity: 0; }
34% { opacity: 0.8; }
56%, 100% { left: 125%; opacity: 0; }
}
@keyframes assembly-pulse {
from { transform: scale(1); opacity: 0.8; }
to { transform: scale(1.08); opacity: 1; }
}
@keyframes halo-spin {
to { transform: rotate(332deg); }
}
@keyframes status-blink {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@keyframes click-highlight {
0% { transform: scale(1); }
16% { transform: scale(1.14); }
42% { transform: scale(1.08); }
100% { transform: scale(1.04); }
}
@keyframes click-ping {
0% { box-shadow: 0 0 0 4px rgba(214, 168, 94, 0.09); }
55% { box-shadow: 0 0 0 11px rgba(214, 168, 94, 0.02); }
100% { box-shadow: 0 0 0 6px rgba(214, 168, 94, 0.06); }
}
@media (max-width: 800px) {
.page-header,
.page-footer {
right: 5%;
left: 5%;
}
.header-meta {
display: none;
}
.metric-row {
min-width: 100%;
}
.metric {
min-width: 0;
flex: 1;
}
.status-line {
display: none;
}
}
/* A portrait fallback keeps the same visual grammar without forcing the
16:9 stage into a postage stamp. Desktop and presentation ratios keep
the strict 16:9 canvas above. */
@media (max-aspect-ratio: 3/4) {
body {
overflow: auto;
place-items: start center;
}
.stage-shell {
width: 100vw;
height: 100vh;
min-height: 680px;
}
.visual-stage {
inset: 19% 5% 18%;
}
.page-header {
top: 5%;
}
.page-footer {
bottom: 5%;
}
.header-meta,
.status-line {
display: none;
}
.metric-row {
width: 100%;
min-width: 100%;
}
.metric {
min-width: 0;
flex: 1;
}
}
@media (max-height: 560px) {
.page-header {
top: 4%;
}
.page-footer {
bottom: 4%;
}
.visual-stage {
top: 16%;
bottom: 16%;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.001ms !important;
}
}
</style>
</head>
<body>
<main class="stage-shell" id="stage" aria-label="中压块结构组成展示">
<div class="ambient-orbit" aria-hidden="true"></div>
<div class="crosshair crosshair-a" aria-hidden="true"><span></span></div>
<div class="crosshair crosshair-b" aria-hidden="true"><span></span></div>
<div class="crosshair crosshair-c" aria-hidden="true"><span></span></div>
<div class="crosshair crosshair-d" aria-hidden="true"><span></span></div>
<!-- Top title stays static while the visual system moves below it. -->
<header class="page-header">
<div class="title-lockup">
<div class="title-kicker">PRODUCT SYSTEM / 01</div>
<h1>中压块 · 结构组成</h1>
<p class="subtitle">MID CLAMP ASSEMBLY</p>
</div>
<div class="header-meta">
<strong>STRUCTURAL COMPONENT STUDY</strong>
<span class="meta-rule" aria-hidden="true"></span>
<span>ORBITAL VIEW / 360°</span>
</div>
</header>
<section class="visual-stage" id="visualStage" aria-label="中压块五个组成部件">
<!-- All track geometry is SVG so the lines stay crisp at any scale. -->
<svg class="orbit-svg" id="orbitSvg" aria-hidden="true" focusable="false">
<circle class="orbit-outer" id="outerOrbit" cx="0" cy="0" r="0"></circle>
<circle class="orbit-inner" id="innerOrbit" cx="0" cy="0" r="0"></circle>
<circle class="orbit-inner-glow" id="innerGlow" cx="0" cy="0" r="0"></circle>
<g id="orbitTicks"></g>
<path class="connector-glow" id="connectorGlow" d=""></path>
<path class="connector-line" id="connectorLine" d=""></path>
<circle class="connector-dot" id="connectorDot" cx="0" cy="0" r="4"></circle>
</svg>
<!-- Center product: replace the src with the finished product image later. -->
<div class="central-product" id="centralProduct" aria-label="中压块成品">
<div class="product-halo" aria-hidden="true"></div>
<div class="product-asset-wrap">
<img class="product-asset" id="productAsset" src="assets/finished-mid-clamp.webp" alt="中压块成品">
<div class="product-sheen" aria-hidden="true"></div>
</div>
<div class="core-caption">
<span class="core-name">中压块成品</span>
<span class="core-en">FINISHED ASSEMBLY</span>
</div>
</div>
<!-- Hover readout is intentionally a text rail, not a large card. -->
<aside class="focus-readout" id="focusReadout" aria-live="polite">
<span class="readout-index" id="readoutIndex">PARTS / 05</span>
<strong class="readout-name" id="readoutName">悬停查看部件</strong>
<span class="readout-en" id="readoutEn">HOVER TO INSPECT</span>
<span class="readout-keyword" id="readoutKeyword">结构关系</span>
<span class="readout-hint" id="readoutHint">PAUSE ORBIT · VIEW DETAIL</span>
</aside>
<div class="assembly-badge" id="assemblyBadge" aria-live="polite">
<span class="assembly-kicker">ASSEMBLY SEQUENCE</span>
<span class="assembly-title">5 PARTS / PRECISION ASSEMBLY</span>
<span class="assembly-cn">精密自动组装</span>
</div>
<!--
Replaceable component data:
1. Change each src to another local image path.
2. Change name / en / keyword without touching the animation code.
The five nodes are intentionally buttons so keyboard focus mirrors hover.
-->
<button class="part-node" data-index="0" data-kind="body" type="button" aria-label="查看中压块主体">
<span class="part-media" data-placeholder="BODY">
<img class="part-image" src="assets/mid-clamp-body.webp" alt="中压块主体">
</span>
<span class="part-copy">
<span class="part-name">中压块主体</span>
<span class="part-en">MID CLAMP BODY</span>
<span class="part-keyword">承载主体</span>
</span>
</button>
<button class="part-node" data-index="1" data-kind="bolt" type="button" aria-label="查看螺栓">
<span class="part-media" data-placeholder="BOLT">
<img class="part-image" src="assets/bolt.webp" alt="螺丝或螺栓">
</span>
<span class="part-copy">
<span class="part-name">螺丝 / 螺栓</span>
<span class="part-en">BOLT FASTENER</span>
<span class="part-keyword">紧固连接</span>
</span>
</button>
<button class="part-node" data-index="2" data-kind="nut" type="button" aria-label="查看螺母">
<span class="part-media" data-placeholder="NUT">
<img class="part-image" src="assets/nut.webp" alt="螺母">
</span>
<span class="part-copy">
<span class="part-name">螺母</span>
<span class="part-en">FLANGE NUT</span>
<span class="part-keyword">锁定定位</span>
</span>
</button>
<button class="part-node" data-index="3" data-kind="washer" type="button" aria-label="查看橡胶垫圈">
<span class="part-media" data-placeholder="WASHER">
<img class="part-image" src="assets/rubber-washer.webp" alt="橡胶垫圈">
</span>
<span class="part-copy">
<span class="part-name">橡胶垫圈</span>
<span class="part-en">RUBBER WASHER</span>
<span class="part-keyword">缓冲防护</span>
</span>
</button>
<button class="part-node" data-index="4" data-kind="plate" type="button" aria-label="查看导电片">
<span class="part-media" data-placeholder="PLATE">
<img class="part-image" src="assets/conductive-plate.webp" alt="导电片">
</span>
<span class="part-copy">
<span class="part-name">导电片</span>
<span class="part-en">CONDUCTIVE PLATE</span>
<span class="part-keyword">稳定导电</span>
</span>
</button>
</section>
<footer class="page-footer">
<div class="metric-row" aria-label="结构展示指标">
<div class="metric">
<span class="metric-value">05</span>
<span class="metric-label">ASSEMBLY PARTS</span>
<span class="metric-cn">五大组装部件</span>
</div>
<div class="metric">
<span class="metric-value">360°</span>
<span class="metric-label">STRUCTURE VIEW</span>
<span class="metric-cn">全方位结构展示</span>
</div>
<div class="metric">
<span class="metric-value">AUTO</span>
<span class="metric-label">ASSEMBLY</span>
<span class="metric-cn">自动组装</span>
</div>
</div>
<div class="status-line" id="statusLine">
<span class="status-dot" aria-hidden="true"></span>
<span id="statusText">LIVE · ORBIT</span>
</div>
</footer>
</main>
<script>
/**
* Mid clamp assembly scene
* ---------------------------------
* The page has no animation framework on purpose. A single rAF loop owns
* both the orbit and the short assembly timeline, which prevents different
* pieces from drifting out of sync.
*/
(() => {
"use strict";
const visualStage = document.getElementById("visualStage");
const orbitSvg = document.getElementById("orbitSvg");
const outerOrbit = document.getElementById("outerOrbit");
const innerOrbit = document.getElementById("innerOrbit");
const innerGlow = document.getElementById("innerGlow");
const orbitTicks = document.getElementById("orbitTicks");
const connectorGlow = document.getElementById("connectorGlow");
const connectorLine = document.getElementById("connectorLine");
const connectorDot = document.getElementById("connectorDot");
const centralProduct = document.getElementById("centralProduct");
const assemblyBadge = document.getElementById("assemblyBadge");
const focusReadout = document.getElementById("focusReadout");
const readoutIndex = document.getElementById("readoutIndex");
const readoutName = document.getElementById("readoutName");
const readoutEn = document.getElementById("readoutEn");
const readoutKeyword = document.getElementById("readoutKeyword");
const readoutHint = document.getElementById("readoutHint");
const statusLine = document.getElementById("statusLine");
const statusText = document.getElementById("statusText");
const partNodes = [...document.querySelectorAll(".part-node")];
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const videoRenderMode = new URLSearchParams(window.location.search).get("render") === "video";
// Change the copy or image path here when the final nomenclature/assets
// arrive. Geometry and animation do not depend on these values.
const partData = [
{ name: "中压块主体", en: "MID CLAMP BODY", keyword: "承载主体" },
{ name: "螺丝 / 螺栓", en: "BOLT FASTENER", keyword: "紧固连接" },
{ name: "螺母", en: "FLANGE NUT", keyword: "锁定定位" },
{ name: "橡胶垫圈", en: "RUBBER WASHER", keyword: "缓冲防护" },
{ name: "导电片", en: "CONDUCTIVE PLATE", keyword: "稳定导电" }
];
const timings = videoRenderMode
? {
brake: 650,
approach: 1400,
hold: 1450,
release: 1400,
resume: 650,
wait: 1100
}
: {
brake: 700,
approach: 1500,
hold: 1350,
release: 1500,
resume: 650,
wait: 5000
};
const videoTimeline = {
orbitLead: 2300,
clickSequence: 5000,
brake: 650,
approach: 1400,
hold: 1450,
release: 1400,
resume: 650
};
const motion = {
baseSpeed: (Math.PI * 2) / 24000,
currentAngle: 0,
phase: "orbit",
phaseStartedAt: 0,
nextAssemblyAt: 0,
lastFrameAt: 0,
hoverIndex: null,
manualPause: false,
clickIndex: null,
clickUntil: 0,
centerX: 0,
centerY: 0,
radius: 0,
coreSize: 0,
width: 0,
height: 0
};
// Pointer hover and keyboard focus are separate inputs. Keeping them
// independent prevents a pointerleave event from cancelling a focused
// button while the user is navigating with Tab.
motion.pointerIndex = null;
motion.focusIndex = null;
let tickSignature = "";
let videoSeekTime = null;
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const lerp = (from, to, amount) => from + (to - from) * amount;
const easeInOut = (value) => {
const t = clamp(value, 0, 1);
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
};
const easeOut = (value) => 1 - Math.pow(1 - clamp(value, 0, 1), 3);
const assemblyPhases = new Set(["brake", "approach", "hold", "release", "resume"]);
const phaseIsAssembly = () => assemblyPhases.has(motion.phase);
function setSvgCircle(circle, radius) {
circle.setAttribute("cx", motion.centerX.toFixed(2));
circle.setAttribute("cy", motion.centerY.toFixed(2));
circle.setAttribute("r", radius.toFixed(2));
}
function drawTicks() {
const count = 40;
const signature = `${motion.width}x${motion.height}:${motion.radius.toFixed(1)}`;
if (signature === tickSignature) return;
tickSignature = signature;
orbitTicks.replaceChildren();
const tickRadius = motion.radius + Math.min(20, motion.radius * 0.08);
const innerTickRadius = motion.radius - Math.min(13, motion.radius * 0.055);
const tickLength = Math.max(5, Math.min(13, motion.radius * 0.045));
for (let index = 0; index < count; index += 1) {
const angle = (Math.PI * 2 * index) / count - Math.PI / 2;
const outerX = motion.centerX + Math.cos(angle) * tickRadius;
const outerY = motion.centerY + Math.sin(angle) * tickRadius;
const length = index % 5 === 0 ? tickLength * 1.7 : tickLength;
const innerX = motion.centerX + Math.cos(angle) * (tickRadius - length);
const innerY = motion.centerY + Math.sin(angle) * (tickRadius - length);
const tick = document.createElementNS("http://www.w3.org/2000/svg", "line");
tick.setAttribute("x1", innerX.toFixed(2));
tick.setAttribute("y1", innerY.toFixed(2));
tick.setAttribute("x2", outerX.toFixed(2));
tick.setAttribute("y2", outerY.toFixed(2));
tick.classList.add("orbit-tick");
if (index % 5 === 0) tick.classList.add("major");
orbitTicks.appendChild(tick);
}
// A very low-contrast internal marker ring adds mechanical scale
// without turning the background into a loud HUD.
const innerMarker = document.createElementNS("http://www.w3.org/2000/svg", "circle");
innerMarker.setAttribute("cx", motion.centerX.toFixed(2));
innerMarker.setAttribute("cy", motion.centerY.toFixed(2));
innerMarker.setAttribute("r", innerTickRadius.toFixed(2));
innerMarker.setAttribute("fill", "none");
innerMarker.setAttribute("stroke", "rgba(159, 186, 179, 0.04)");
innerMarker.setAttribute("stroke-width", "1");
innerMarker.setAttribute("vector-effect", "non-scaling-stroke");
orbitTicks.appendChild(innerMarker);
}
function measureScene() {
const bounds = visualStage.getBoundingClientRect();
motion.width = bounds.width;
motion.height = bounds.height;
motion.centerX = bounds.width * 0.5;
motion.centerY = bounds.height * 0.51;
// Keep enough room for the short labels at the upper and lower nodes.
const available = Math.min(bounds.width, bounds.height);
motion.radius = clamp(available * 0.39, 80, 292);
motion.coreSize = clamp(available * 0.42, 116, 300);
orbitSvg.setAttribute("viewBox", `0 0 ${bounds.width} ${bounds.height}`);
setSvgCircle(outerOrbit, motion.radius + Math.min(22, motion.radius * 0.09));
setSvgCircle(innerOrbit, motion.radius - Math.min(16, motion.radius * 0.06));
setSvgCircle(innerGlow, motion.radius - Math.min(16, motion.radius * 0.06));
centralProduct.style.width = `${motion.coreSize}px`;
centralProduct.style.left = `${motion.centerX}px`;
centralProduct.style.top = `${motion.centerY}px`;
assemblyBadge.style.left = `${motion.centerX}px`;
assemblyBadge.style.top = `${motion.centerY + motion.coreSize * 0.55}px`;
drawTicks();
}
function orbitPosition(index, angle = motion.currentAngle) {
const baseAngle = -Math.PI / 2 + index * ((Math.PI * 2) / partNodes.length);
const theta = baseAngle + angle;
// Required circular motion model:
// x = centerX + radius * cos(angle)
// y = centerY + radius * sin(angle)
return {
x: motion.centerX + motion.radius * Math.cos(theta),
y: motion.centerY + motion.radius * Math.sin(theta),
theta
};
}
function assemblyPosition(index) {
const assemblyRadius = Math.min(40, motion.radius * 0.09);
const theta = -Math.PI / 2 + index * ((Math.PI * 2) / partNodes.length);
return {
x: motion.centerX + assemblyRadius * Math.cos(theta),
y: motion.centerY + assemblyRadius * Math.sin(theta),
theta
};
}
function getNodePosition(index, now) {
const orbitPoint = orbitPosition(index);
const elapsed = now - motion.phaseStartedAt;
if (motion.phase === "approach") {
const delay = index * 105;
const progress = easeInOut(clamp((elapsed - delay) / 780, 0, 1));
const target = assemblyPosition(index);
return {
x: lerp(orbitPoint.x, target.x, progress),
y: lerp(orbitPoint.y, target.y, progress),
scale: lerp(1, 0.58, progress),
opacity: lerp(1, 0.16, progress),
theta: orbitPoint.theta
};
}
if (motion.phase === "hold") {
const target = assemblyPosition(index);
return { x: target.x, y: target.y, scale: 0.52, opacity: 0.12, theta: target.theta };
}
if (motion.phase === "release") {
const delay = (partNodes.length - 1 - index) * 105;
const progress = easeOut(clamp((elapsed - delay) / 780, 0, 1));
const source = assemblyPosition(index);
return {
x: lerp(source.x, orbitPoint.x, progress),
y: lerp(source.y, orbitPoint.y, progress),
scale: lerp(0.52, 1, progress),
opacity: lerp(0.12, 1, progress),
theta: orbitPoint.theta
};
}
return { ...orbitPoint, scale: 1, opacity: 1 };
}
function updateConnector() {
const hasFocus = motion.hoverIndex !== null && !phaseIsAssembly();
visualStage.classList.toggle("has-focus", hasFocus);
focusReadout.classList.toggle("has-focus", hasFocus);
if (!hasFocus) {
connectorGlow.setAttribute("d", "");
connectorLine.setAttribute("d", "");
return;
}
const nodePoint = orbitPosition(motion.hoverIndex);
const dx = nodePoint.x - motion.centerX;
const dy = nodePoint.y - motion.centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
const normalX = -dy / distance;
const normalY = dx / distance;
const bend = Math.min(50, distance * 0.12) * (motion.hoverIndex % 2 === 0 ? 1 : -1);
const midX = motion.centerX + dx * 0.5 + normalX * bend;
const midY = motion.centerY + dy * 0.5 + normalY * bend;
const path = `M ${motion.centerX.toFixed(2)} ${motion.centerY.toFixed(2)} Q ${midX.toFixed(2)} ${midY.toFixed(2)} ${nodePoint.x.toFixed(2)} ${nodePoint.y.toFixed(2)}`;
connectorGlow.setAttribute("d", path);
connectorLine.setAttribute("d", path);
connectorDot.setAttribute("cx", nodePoint.x.toFixed(2));
connectorDot.setAttribute("cy", nodePoint.y.toFixed(2));
}
function updateReadout() {
const hasFocus = motion.hoverIndex !== null && !phaseIsAssembly();
if (!hasFocus) {
readoutIndex.textContent = "PARTS / 05";
readoutName.textContent = "悬停查看部件";
readoutEn.textContent = "HOVER TO INSPECT";
readoutKeyword.textContent = "结构关系";
readoutHint.textContent = "PAUSE ORBIT · VIEW DETAIL";
return;
}
const part = partData[motion.hoverIndex];
readoutIndex.textContent = `PART ${String(motion.hoverIndex + 1).padStart(2, "0")} / 05`;
readoutName.textContent = part.name;
readoutEn.textContent = part.en;
readoutKeyword.textContent = part.keyword;
}
function updatePartNodes(now) {
const hasFocus = motion.hoverIndex !== null && !phaseIsAssembly();
partNodes.forEach((node, index) => {
const point = getNodePosition(index, now);
const focused = hasFocus && index === motion.hoverIndex;
const dimmed = hasFocus && index !== motion.hoverIndex;
node.style.left = `${point.x}px`;
node.style.top = `${point.y}px`;
node.style.opacity = `${dimmed ? point.opacity * 0.35 : point.opacity}`;
node.style.setProperty("--node-scale", `${point.scale * (focused ? 1.08 : 1)}`);
node.classList.toggle("is-hovered", focused);
node.classList.toggle("is-dimmed", dimmed);
node.classList.toggle("is-click-highlight", motion.clickIndex === index);
node.classList.toggle("is-assembly", phaseIsAssembly());
});
}
function setStatus() {
const labels = {
orbit: "LIVE · ORBIT",
click: motion.hoverIndex === null ? "FOCUS · PART" : `FOCUS · PART ${String(motion.hoverIndex + 1).padStart(2, "0")} / 05`,
brake: "SYNC · DECELERATING",
approach: "ASSEMBLY · ALIGNING",
hold: "ASSEMBLY · LOCKED",
release: "RESET · RELEASING",
resume: "LIVE · RESUMING"
};
statusText.textContent = labels[motion.phase];
statusLine.classList.toggle("is-paused", motion.manualPause && motion.phase === "orbit");
}
function renderVideoAt(elapsedMilliseconds) {
const elapsed = Math.max(0, Number(elapsedMilliseconds) || 0);
const leadEnd = videoTimeline.orbitLead;
const clickEnd = leadEnd + videoTimeline.clickSequence;
const brakeEnd = clickEnd + videoTimeline.brake;
const approachEnd = brakeEnd + videoTimeline.approach;
const holdEnd = approachEnd + videoTimeline.hold;
const releaseEnd = holdEnd + videoTimeline.release;
const resumeEnd = releaseEnd + videoTimeline.resume;
let phaseStart = 0;
let phase = "orbit";
let selectedIndex = null;
let angle = motion.baseSpeed * Math.min(elapsed, leadEnd);
if (elapsed >= leadEnd && elapsed < clickEnd) {
phase = "click";
const clickElapsed = elapsed - leadEnd;
selectedIndex = Math.min(partNodes.length - 1, Math.floor(clickElapsed / 1000));
phaseStart = leadEnd + selectedIndex * 1000;
angle = motion.baseSpeed * leadEnd;
} else if (elapsed >= clickEnd && elapsed < brakeEnd) {
phase = "brake";
phaseStart = clickEnd;
const progress = easeInOut((elapsed - clickEnd) / videoTimeline.brake);
angle = motion.baseSpeed * (leadEnd + (elapsed - clickEnd) * (1 - progress * 0.5));
} else if (elapsed >= brakeEnd && elapsed < approachEnd) {
phase = "approach";
phaseStart = brakeEnd;
angle = motion.baseSpeed * (leadEnd + videoTimeline.brake * 0.5);
} else if (elapsed >= approachEnd && elapsed < holdEnd) {
phase = "hold";
phaseStart = approachEnd;
angle = motion.baseSpeed * (leadEnd + videoTimeline.brake * 0.5);
} else if (elapsed >= holdEnd && elapsed < releaseEnd) {
phase = "release";
phaseStart = holdEnd;
angle = motion.baseSpeed * (leadEnd + videoTimeline.brake * 0.5);
} else if (elapsed >= releaseEnd && elapsed < resumeEnd) {
phase = "resume";
phaseStart = releaseEnd;
const progress = easeInOut((elapsed - releaseEnd) / videoTimeline.resume);
angle = motion.baseSpeed * (leadEnd + videoTimeline.brake * 0.5) + motion.baseSpeed * progress * 0.8;
} else if (elapsed >= resumeEnd) {
phase = "orbit";
phaseStart = resumeEnd;
angle = motion.baseSpeed * (leadEnd + videoTimeline.brake * 0.5) + motion.baseSpeed * 0.8 + motion.baseSpeed * (elapsed - resumeEnd);
}
motion.phase = phase;
motion.phaseStartedAt = phaseStart;
motion.currentAngle = angle;
motion.hoverIndex = selectedIndex;
motion.clickIndex = selectedIndex;
motion.clickUntil = selectedIndex === null ? 0 : Number.POSITIVE_INFINITY;
motion.manualPause = selectedIndex !== null;
updatePartNodes(elapsed);
updateConnector();
updateReadout();
setStatus();
centralProduct.classList.toggle("is-assembly", phase === "hold" || (phase === "approach" && elapsed - phaseStart > 800));
assemblyBadge.classList.toggle("is-visible", phase === "hold");
}
function beginAssembly(now) {
motion.phase = "brake";
motion.phaseStartedAt = now;
motion.nextAssemblyAt = Number.POSITIVE_INFINITY;
motion.hoverIndex = null;
motion.pointerIndex = null;
motion.focusIndex = null;
motion.clickIndex = null;
motion.clickUntil = 0;
motion.manualPause = false;
updateReadout();
}
function advancePhase(now) {
if (motion.phase === "orbit" && now >= motion.nextAssemblyAt) {
beginAssembly(now);
return;
}
const phaseDuration = timings[motion.phase];
if (!phaseDuration || now - motion.phaseStartedAt < phaseDuration) return;
const phaseOrder = ["brake", "approach", "hold", "release", "resume"];
const phaseIndex = phaseOrder.indexOf(motion.phase);
if (motion.phase === "resume") {
motion.phase = "orbit";
motion.phaseStartedAt = now;
motion.nextAssemblyAt = now + timings.wait;
} else {
motion.phase = phaseOrder[phaseIndex + 1];
motion.phaseStartedAt = now;
}
}
function currentSpeed(now) {
if (motion.manualPause || reducedMotionQuery.matches) return 0;
const elapsed = now - motion.phaseStartedAt;
if (motion.phase === "brake") {
return motion.baseSpeed * (1 - easeInOut(elapsed / timings.brake));
}
if (motion.phase === "resume") {
return motion.baseSpeed * easeInOut(elapsed / timings.resume);
}
if (phaseIsAssembly()) return 0;
return motion.baseSpeed;
}
function frame(now) {
if (videoRenderMode && videoSeekTime !== null) {
renderVideoAt(videoSeekTime);
requestAnimationFrame(frame);
return;
}
if (!motion.lastFrameAt) {
motion.lastFrameAt = now;
motion.phaseStartedAt = now;
motion.nextAssemblyAt = now + (videoRenderMode ? 2300 : 10000);
}
const delta = Math.min(50, now - motion.lastFrameAt);
motion.lastFrameAt = now;
if (motion.clickIndex !== null && now >= motion.clickUntil) {
motion.clickIndex = null;
motion.clickUntil = 0;
syncInteraction();
}
if (reducedMotionQuery.matches) {
motion.phase = "orbit";
motion.currentAngle = 0;
motion.nextAssemblyAt = Number.POSITIVE_INFINITY;
} else {
advancePhase(now);
motion.currentAngle += currentSpeed(now) * delta;
}
updatePartNodes(now);
updateConnector();
updateReadout();
setStatus();
centralProduct.classList.toggle("is-assembly", motion.phase === "hold" || (motion.phase === "approach" && now - motion.phaseStartedAt > 800));
assemblyBadge.classList.toggle("is-visible", motion.phase === "hold");
requestAnimationFrame(frame);
}
// The video renderer calls this after the assets have loaded. It makes
// capture deterministic even if the browser spent time warming up the
// file URL before the first frame was requested.
if (videoRenderMode) {
window.__midClampVideoStart = () => {
videoSeekTime = 0;
motion.currentAngle = 0;
motion.phase = "orbit";
motion.phaseStartedAt = 0;
motion.nextAssemblyAt = Number.POSITIVE_INFINITY;
motion.lastFrameAt = 0;
motion.hoverIndex = null;
motion.pointerIndex = null;
motion.focusIndex = null;
motion.clickIndex = null;
motion.clickUntil = 0;
motion.manualPause = false;
renderVideoAt(0);
};
window.__midClampVideoSeek = (elapsedMilliseconds) => {
videoSeekTime = Number(elapsedMilliseconds) || 0;
renderVideoAt(videoSeekTime);
};
}
function syncInteraction() {
// A click pulse owns the focus for its one-second window. Outside of
// that window, pointer input wins and keyboard focus resumes as soon
// as the pointer leaves the node.
motion.hoverIndex = motion.clickIndex ?? motion.pointerIndex ?? motion.focusIndex;
motion.manualPause = motion.hoverIndex !== null;
}
function setPointer(index, active) {
if (phaseIsAssembly() || reducedMotionQuery.matches) return;
if (active) {
motion.pointerIndex = index;
} else if (motion.pointerIndex === index) {
motion.pointerIndex = null;
}
syncInteraction();
updateReadout();
}
function setFocus(index, active) {
if (phaseIsAssembly() || reducedMotionQuery.matches) return;
motion.focusIndex = active ? index : null;
syncInteraction();
updateReadout();
}
partNodes.forEach((node, index) => {
node.addEventListener("pointerenter", () => setPointer(index, true));
node.addEventListener("pointerleave", () => setPointer(index, false));
node.addEventListener("focus", () => setFocus(index, true));
node.addEventListener("blur", () => setFocus(index, false));
node.addEventListener("click", (event) => {
if (phaseIsAssembly() || reducedMotionQuery.matches) return;
// Mouse activation is a timed inspection pulse. Keyboard activation
// keeps its normal focus behavior, while the click pulse still gives
// it the same visual feedback.
if (event.detail > 0) motion.focusIndex = null;
motion.clickIndex = index;
motion.clickUntil = performance.now() + 1000;
syncInteraction();
updateReadout();
});
const image = node.querySelector("img");
image.addEventListener("error", () => node.classList.add("is-missing"), { once: true });
});
const productAsset = document.getElementById("productAsset");
productAsset.addEventListener("error", () => productAsset.closest(".product-asset-wrap").classList.add("is-missing"), { once: true });
const resizeObserver = new ResizeObserver(measureScene);
resizeObserver.observe(visualStage);
window.addEventListener("resize", measureScene, { passive: true });
reducedMotionQuery.addEventListener?.("change", () => {
measureScene();
motion.lastFrameAt = 0;
});
measureScene();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>