公司新闻

公司新闻

公司新闻 行业新闻

ceshi

时间: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>


上一篇:多工位自动组装设备:从单一产品组装走向柔性化自动生产

列表

下一篇:没有了

相关新闻