This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/seatunnel-website.git
The following commit(s) were added to refs/heads/main by this push:
new a033bdbdbee4 [Docs] Replace homepage motion iframe with native cards
(#538)
a033bdbdbee4 is described below
commit a033bdbdbee4f75841a6e0ca9aebaf01469fac88
Author: Daniel <[email protected]>
AuthorDate: Mon Sep 21 22:17:59 2026 -0400
[Docs] Replace homepage motion iframe with native cards (#538)
---
src/pages/home/MotionMechanismGrid.jsx | 225 +++++++++++++++++++
src/pages/home/index.jsx | 175 ++-------------
src/pages/home/index.less | 388 +++++++++++++++++++++------------
static/home/seatunnel-motion.html | 329 ----------------------------
4 files changed, 500 insertions(+), 617 deletions(-)
diff --git a/src/pages/home/MotionMechanismGrid.jsx
b/src/pages/home/MotionMechanismGrid.jsx
new file mode 100644
index 000000000000..cfce5436a2ec
--- /dev/null
+++ b/src/pages/home/MotionMechanismGrid.jsx
@@ -0,0 +1,225 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+function Arrow({x, y}) {
+ return <path d={`M${x - 6} ${y - 5}l6 5-6 5`}
className="st-home-motion-diagram-arrow" fill="none" />;
+}
+
+function ParallelReadsDiagram() {
+ return (
+ <svg viewBox="0 0 520 166" className="st-home-motion-diagram"
aria-hidden="true">
+ <text x="18" y="20"
className="st-home-motion-diagram-label">SOURCE SPLITS</text>
+ <text x="207" y="20"
className="st-home-motion-diagram-label">READERS</text>
+ <text x="438" y="20"
className="st-home-motion-diagram-label">TARGET</text>
+ <rect x="16" y="32" width="118" height="112" rx="10"
className="st-home-motion-diagram-node" />
+ {['S1', 'S2', 'S3', 'S4', 'S5', 'S6'].map((split, index) => {
+ const x = 28 + (index % 2) * 49;
+ const y = 45 + Math.floor(index / 2) * 31;
+ const active = index === 1 || index === 3;
+ return (
+ <g key={split}>
+ <rect x={x} y={y} width="39" height="22" rx="5"
className={active ? 'st-home-motion-diagram-packet' :
'st-home-motion-diagram-chip'} />
+ <text
+ x={x + 19.5}
+ y={y + 15}
+ textAnchor="middle"
+ className={
+ active
+ ? 'st-home-motion-diagram-on-blue'
+ : 'st-home-motion-diagram-chip-text'
+ }
+ >
+ {split}
+ </text>
+ </g>
+ );
+ })}
+ {[54, 87, 120].map((y, index) => (
+ <g key={y}>
+ <path d={`M134 88 C168 88 174 ${y} 204 ${y}`}
className="st-home-motion-diagram-line" fill="none" />
+ <rect x="205" y={y - 13} width="121" height="26" rx="7"
className="st-home-motion-diagram-node" />
+ <circle cx="220" cy={y} r="3.5" className={index === 1 ?
'st-home-motion-diagram-teal-fill' : 'st-home-motion-diagram-blue-fill'} />
+ <text x="232" y={y + 5}
className="st-home-motion-diagram-copy">Reader {index + 1}</text>
+ <path d={`M326 ${y} C364 ${y} 384 88 428 88`}
className="st-home-motion-diagram-line" fill="none" />
+ </g>
+ ))}
+ <Arrow x="427" y="88" />
+ <ellipse cx="466" cy="67" rx="27" ry="7"
className="st-home-motion-diagram-node" />
+ <path d="M439 67v43c0 9 54 9 54 0V67"
className="st-home-motion-diagram-node" />
+ <path d="M439 89c0 9 54 9 54 0M439 110c0 9 54 9 54 0"
className="st-home-motion-diagram-line" fill="none" />
+ </svg>
+ );
+}
+
+function SnapshotCdcDiagram() {
+ return (
+ <svg viewBox="0 0 520 166" className="st-home-motion-diagram"
aria-hidden="true">
+ <text x="18" y="20" className="st-home-motion-diagram-label">MYSQL
/ ORDERS</text>
+ <text x="373" y="20"
className="st-home-motion-diagram-label">TARGET / ORDERS</text>
+ <rect x="16" y="31" width="136" height="103" rx="9"
className="st-home-motion-diagram-node" />
+ <rect x="17" y="32" width="134" height="24" rx="7"
className="st-home-motion-diagram-soft" />
+ <text x="29" y="48"
className="st-home-motion-diagram-label">id</text><text x="76" y="48"
className="st-home-motion-diagram-label">status</text>
+ <text x="29" y="77"
className="st-home-motion-diagram-copy">41</text><text x="76" y="77"
className="st-home-motion-diagram-copy">NEW</text>
+ <text x="29" y="104"
className="st-home-motion-diagram-copy">42</text><text x="76" y="104"
className="st-home-motion-diagram-copy">NEW</text>
+ <rect x="201" y="24" width="98" height="23" rx="12"
className="st-home-motion-diagram-soft" />
+ <text x="250" y="40" textAnchor="middle"
className="st-home-motion-diagram-accent-text">SNAPSHOT</text>
+ <path d="M153 82H367" className="st-home-motion-diagram-line"
fill="none" />
+ <Arrow x="367" y="82" />
+ <rect x="235" y="70" width="48" height="23" rx="7"
className="st-home-motion-diagram-teal" />
+ <text x="259" y="86" textAnchor="middle"
className="st-home-motion-diagram-on-teal">S:42</text>
+ <text x="260" y="116" textAnchor="middle"
className="st-home-motion-diagram-copy">SeaTunnel</text>
+ <rect x="370" y="31" width="136" height="103" rx="9"
className="st-home-motion-diagram-node" />
+ <rect x="371" y="32" width="134" height="24" rx="7"
className="st-home-motion-diagram-soft" />
+ <text x="383" y="48"
className="st-home-motion-diagram-label">id</text><text x="430" y="48"
className="st-home-motion-diagram-label">status</text>
+ <text x="383" y="77"
className="st-home-motion-diagram-copy">41</text><text x="430" y="77"
className="st-home-motion-diagram-copy">NEW</text>
+ </svg>
+ );
+}
+
+function TransformDiagram() {
+ return (
+ <svg viewBox="0 0 520 166" className="st-home-motion-diagram"
aria-hidden="true">
+ <text x="18" y="20"
className="st-home-motion-diagram-label">INPUT</text>
+ <text x="218" y="20" className="st-home-motion-diagram-label">ROW
SQL</text>
+ <text x="421" y="20"
className="st-home-motion-diagram-label">OUTPUT</text>
+ <rect x="16" y="31" width="125" height="91" rx="9"
className="st-home-motion-diagram-node" />
+ <text x="28" y="57"
className="st-home-motion-diagram-copy">1 Ada</text>
+ <text x="28" y="82"
className="st-home-motion-diagram-copy">0 Temp</text>
+ <text x="28" y="107"
className="st-home-motion-diagram-copy">2 Lin</text>
+ <path d="M142 76H206M315 76h84"
className="st-home-motion-diagram-line" fill="none" />
+ <Arrow x="399" y="76" />
+ <rect x="207" y="48" width="108" height="56" rx="13"
className="st-home-motion-diagram-blue" />
+ <text x="261" y="82" textAnchor="middle"
className="st-home-motion-diagram-on-blue st-home-motion-diagram-sql">SQL</text>
+ <rect x="402" y="31" width="102" height="91" rx="9"
className="st-home-motion-diagram-node" />
+ <rect x="365" y="64" width="24" height="24" rx="7"
className="st-home-motion-diagram-teal" />
+ <text x="377" y="81" textAnchor="middle"
className="st-home-motion-diagram-on-teal">1</text>
+ <text x="413" y="77"
className="st-home-motion-diagram-muted-copy">waiting…</text>
+ <text x="18" y="144"
className="st-home-motion-diagram-code">CONCAT(name, '_')</text>
+ </svg>
+ );
+}
+
+function RoutingDiagram() {
+ const rows = ['users', 'orders', 'items'];
+ return (
+ <svg viewBox="0 0 520 166" className="st-home-motion-diagram"
aria-hidden="true">
+ {rows.map((table, index) => {
+ const y = 39 + index * 43;
+ return (
+ <g key={table}>
+ <rect x="16" y={y} width="127" height="30" rx="7"
className="st-home-motion-diagram-node" />
+ <rect x="377" y={y} width="127" height="30" rx="7"
className="st-home-motion-diagram-node" />
+ <rect x="28" y={y + 8} width="16" height="14" rx="2"
className="st-home-motion-diagram-table" />
+ <rect x="389" y={y + 8} width="16" height="14" rx="2"
className="st-home-motion-diagram-table" />
+ <text x="55" y={y + 20}
className="st-home-motion-diagram-copy">{table}</text>
+ <text x="416" y={y + 20}
className="st-home-motion-diagram-copy">{table}</text>
+ <path d={`M144 ${y + 15} C179 ${y + 15} 189 82 221
82M299 82 C333 82 343 ${y + 15} 376 ${y + 15}`}
className="st-home-motion-diagram-line" fill="none" />
+ <Arrow x="375" y={y + 15} />
+ </g>
+ );
+ })}
+ <rect x="221" y="52" width="78" height="61" rx="12"
className="st-home-motion-diagram-blue" />
+ <text x="260" y="78" textAnchor="middle"
className="st-home-motion-diagram-on-blue">Table</text>
+ <text x="260" y="98" textAnchor="middle"
className="st-home-motion-diagram-on-blue">route</text>
+ </svg>
+ );
+}
+
+function SchemaDiagram() {
+ const rows = [['id', 'BIGINT'], ['name', 'VARCHAR'], ['region',
'VARCHAR']];
+ return (
+ <svg viewBox="0 0 520 166" className="st-home-motion-diagram"
aria-hidden="true">
+ <text x="18" y="20" className="st-home-motion-diagram-label">MYSQL
SOURCE</text>
+ <text x="371" y="20" className="st-home-motion-diagram-label">JDBC
MYSQL SINK</text>
+ {[16, 369].map((x) => <rect key={x} x={x} y="31" width="135"
height="104" rx="9" className="st-home-motion-diagram-node" />)}
+ {[16, 369].map((x) => <g key={x}><rect x={x + 1} y="32"
width="133" height="24" rx="7" className="st-home-motion-diagram-soft" /><text
x={x + 14} y="48" className="st-home-motion-diagram-copy">▦
customers</text></g>)}
+ {rows.map(([name, type], index) => {
+ const y = 76 + index * 24;
+ return (
+ <g key={name}>
+ {index === 2 ? <rect x="22" y={y - 16} width="123"
height="22" rx="4" className="st-home-motion-diagram-highlight" /> : null}
+ <text x="29" y={y} className={index === 2 ?
'st-home-motion-diagram-teal-copy' :
'st-home-motion-diagram-copy'}>{name}</text>
+ <text x="92" y={y} className={index === 2 ?
'st-home-motion-diagram-teal-copy' :
'st-home-motion-diagram-code'}>{type}</text>
+ <text x="382" y={y} className={index === 2 ?
'st-home-motion-diagram-teal-copy' :
'st-home-motion-diagram-copy'}>{name}</text>
+ <text x="445" y={y} className={index === 2 ?
'st-home-motion-diagram-teal-copy' :
'st-home-motion-diagram-code'}>{type}</text>
+ </g>
+ );
+ })}
+ <path d="M152 82H364" className="st-home-motion-diagram-line"
fill="none" /><Arrow x="364" y="82" />
+ <rect x="225" y="48" width="58" height="22" rx="11"
className="st-home-motion-diagram-soft" /><text x="254" y="64"
textAnchor="middle" className="st-home-motion-diagram-accent-text">DDL</text>
+ <rect x="217" y="74" width="42" height="23" rx="7"
className="st-home-motion-diagram-blue" /><text x="238" y="90"
textAnchor="middle" className="st-home-motion-diagram-on-blue">ADD</text>
+ </svg>
+ );
+}
+
+function CheckpointDiagram() {
+ return (
+ <svg viewBox="0 0 520 166" className="st-home-motion-diagram"
aria-hidden="true">
+ <path d="M122 70h70M327 70h70"
className="st-home-motion-diagram-line" fill="none" /><Arrow x="192" y="70"
/><Arrow x="397" y="70" />
+ {['Source', 'Writer', 'Sink'].map((label, index) => {
+ const x = [16, 198, 403][index];
+ return <g key={label}><rect x={x} y="47" width="104"
height="43" rx="10" className="st-home-motion-diagram-node" /><text x={x + 52}
y="74" textAnchor="middle"
className="st-home-motion-diagram-strong">{label}</text></g>;
+ })}
+ <rect x="232" y="20" width="70" height="21" rx="10"
className="st-home-motion-diagram-soft" /><text x="267" y="35"
textAnchor="middle"
className="st-home-motion-diagram-accent-text">BARRIER</text>
+ <path d="M68 91C68 130 157 137 215 137M250 91v25"
className="st-home-motion-diagram-line" fill="none" />
+ <rect x="215" y="114" width="134" height="38" rx="9"
className="st-home-motion-diagram-node" />
+ <text x="282" y="135" textAnchor="middle"
className="st-home-motion-diagram-copy">Checkpoint state</text>
+ <text x="282" y="147" textAnchor="middle"
className="st-home-motion-diagram-label">COMPLETED</text>
+ </svg>
+ );
+}
+
+function MotionDiagram({type}) {
+ const diagrams = {
+ parallel: <ParallelReadsDiagram />,
+ cdc: <SnapshotCdcDiagram />,
+ transform: <TransformDiagram />,
+ routing: <RoutingDiagram />,
+ schema: <SchemaDiagram />,
+ checkpoint: <CheckpointDiagram />,
+ };
+
+ return diagrams[type];
+}
+
+export default function MotionMechanismGrid({caption, gridLabel, mechanisms}) {
+ return (
+ <>
+ <ul className="st-home-motion-grid" aria-label={gridLabel}>
+ {mechanisms.map((mechanism, index) => (
+ <li key={mechanism.title} className={`st-home-motion-card
st-home-motion-card-${mechanism.diagram}`}>
+ <article>
+ <div className="st-home-motion-card-heading">
+ <span>{String(index + 1).padStart(2,
'0')}</span>
+ <div>
+ <h3>{mechanism.title}</h3>
+ <p>{mechanism.description}</p>
+ </div>
+ </div>
+ <div
className="st-home-motion-visual"><MotionDiagram type={mechanism.diagram}
/></div>
+ <p
className="st-home-motion-card-detail">{mechanism.detail}</p>
+ <p
className="st-home-motion-card-note">{mechanism.note}</p>
+ </article>
+ </li>
+ ))}
+ </ul>
+ <p className="st-home-motion-caption">{caption}</p>
+ </>
+ );
+}
diff --git a/src/pages/home/index.jsx b/src/pages/home/index.jsx
index 6d9f656faa8b..70bab5d4458b 100644
--- a/src/pages/home/index.jsx
+++ b/src/pages/home/index.jsx
@@ -15,12 +15,13 @@
* limitations under the License.
*/
-import React, {useEffect, useRef, useState} from 'react';
+import React, {useEffect, useRef} from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsBrowser from '@docusaurus/useIsBrowser';
import useBaseUrl from '@docusaurus/useBaseUrl';
import './index.less';
import systemConfiguration from '../../js/sysConfig';
+import MotionMechanismGrid from './MotionMechanismGrid';
const versions = require('../../../versions.json');
@@ -260,24 +261,17 @@ const HOME_COPY = {
],
},
motion: {
- eyebrow: 'See it in motion',
- title: 'Six mechanisms. One integration layer.',
- lead: 'Explore independent examples of parallel reads, CDC, row
transforms, table routing, schema evolution, and checkpoint recovery.',
- openButton: 'Open interactive demo',
- frameTitle: 'Interactive SeaTunnel data integration mechanisms
demo',
- controls: {
- label: 'Motion demo controls',
- play: 'Play animation',
- pause: 'Pause animation',
- overview: 'All mechanisms',
- },
+ title: 'Data integration, in motion.',
+ lead: 'Six mechanisms that keep data moving through parallel
workloads, continuous change, and recoverable failures.',
+ gridLabel: 'SeaTunnel data integration mechanisms',
+ caption: 'Illustrative flows—not a deployment blueprint.
Production behavior depends on the selected connector, engine, and
configuration.',
mechanisms: [
- {title: 'Parallel reads', description: 'Assign independent
source splits to parallel readers.'},
- {title: 'Snapshot + CDC', description: 'Start with a snapshot,
then capture changes.'},
- {title: 'Row transforms', description: 'Apply expressions and
filters to individual records.'},
- {title: 'Multi-table routing', description: 'Route each table
identity to its matching destination.'},
- {title: 'Schema evolution', description: 'Apply supported
schema changes before data moves downstream.'},
- {title: 'Checkpoint recovery', description: 'Resume from
completed checkpoint state after a failure.'},
+ {diagram: 'parallel', title: 'Parallel batch reads',
description: 'Divide the work. Read independent splits in parallel.', detail:
'Independent readers process assigned source splits.', note: 'JDBC example ·
Split assignment is source-specific.'},
+ {diagram: 'cdc', title: 'Snapshot + CDC', description:
'Bootstrap a snapshot, then keep changes flowing.', detail: 'Capture existing
rows before streaming changes.', note: 'MySQL-CDC example · startup.mode =
initial.'},
+ {diagram: 'transform', title: 'Row-level transforms',
description: 'Shape, filter, and enrich records before they land.', detail:
'Apply row expressions and filters in the pipeline.', note: 'SQL example ·
expression + row filter.'},
+ {diagram: 'routing', title: 'Multi-table routing',
description: 'Keep every record on its intended table path.', detail: 'Table
identity selects the matching destination writer.', note: 'Logical routing ·
Physical connections may differ.'},
+ {diagram: 'schema', title: 'Schema evolution', description:
'Apply supported table changes before new data moves downstream.', detail:
'Propagate an added column through the compatible path.', note: 'Zeta example ·
MySQL-CDC → JDBC MySQL.'},
+ {diagram: 'checkpoint', title: 'Checkpoint recovery',
description: 'Resume from a completed checkpoint after a failure.', detail:
'Capture state with a checkpoint barrier.', note: 'Exactly-once requires
compatible source and sink support.'},
],
},
connectors: {
@@ -528,24 +522,17 @@ const HOME_COPY = {
],
},
motion: {
- eyebrow: 'See it in motion',
- title: 'Six mechanisms. One integration layer.',
- lead: 'Explore independent examples of parallel reads, CDC, row
transforms, table routing, schema evolution, and checkpoint recovery.',
- openButton: 'Open interactive demo',
- frameTitle: 'Interactive SeaTunnel data integration mechanisms
demo',
- controls: {
- label: 'Motion demo controls',
- play: 'Play animation',
- pause: 'Pause animation',
- overview: 'All mechanisms',
- },
+ title: 'Data integration, in motion.',
+ lead: 'Six mechanisms that keep data moving through parallel
workloads, continuous change, and recoverable failures.',
+ gridLabel: 'SeaTunnel data integration mechanisms',
+ caption: 'Illustrative flows—not a deployment blueprint.
Production behavior depends on the selected connector, engine, and
configuration.',
mechanisms: [
- {title: 'Parallel reads', description: 'Assign independent
source splits to parallel readers.'},
- {title: 'Snapshot + CDC', description: 'Start with a snapshot,
then capture changes.'},
- {title: 'Row transforms', description: 'Apply expressions and
filters to individual records.'},
- {title: 'Multi-table routing', description: 'Route each table
identity to its matching destination.'},
- {title: 'Schema evolution', description: 'Apply supported
schema changes before data moves downstream.'},
- {title: 'Checkpoint recovery', description: 'Resume from
completed checkpoint state after a failure.'},
+ {diagram: 'parallel', title: 'Parallel batch reads',
description: 'Divide the work. Read independent splits in parallel.', detail:
'Independent readers process assigned source splits.', note: 'JDBC example ·
Split assignment is source-specific.'},
+ {diagram: 'cdc', title: 'Snapshot + CDC', description:
'Bootstrap a snapshot, then keep changes flowing.', detail: 'Capture existing
rows before streaming changes.', note: 'MySQL-CDC example · startup.mode =
initial.'},
+ {diagram: 'transform', title: 'Row-level transforms',
description: 'Shape, filter, and enrich records before they land.', detail:
'Apply row expressions and filters in the pipeline.', note: 'SQL example ·
expression + row filter.'},
+ {diagram: 'routing', title: 'Multi-table routing',
description: 'Keep every record on its intended table path.', detail: 'Table
identity selects the matching destination writer.', note: 'Logical routing ·
Physical connections may differ.'},
+ {diagram: 'schema', title: 'Schema evolution', description:
'Apply supported table changes before new data moves downstream.', detail:
'Propagate an added column through the compatible path.', note: 'Zeta example ·
MySQL-CDC → JDBC MySQL.'},
+ {diagram: 'checkpoint', title: 'Checkpoint recovery',
description: 'Resume from a completed checkpoint after a failure.', detail:
'Capture state with a checkpoint barrier.', note: 'Exactly-once requires
compatible source and sink support.'},
],
},
connectors: {
@@ -670,17 +657,6 @@ function handleFeatureGlow(event) {
card.style.setProperty('--my', `${((event.clientY - rect.top) /
rect.height) * 100}%`);
}
-function prepareMotionDemo(event) {
- const frameDocument = event.currentTarget.contentDocument;
- if (!frameDocument) {
- return;
- }
-
- frameDocument.querySelector('main')?.style.setProperty('padding', '0');
- frameDocument.querySelector('nav')?.setAttribute('hidden', '');
- frameDocument.querySelector('#hint')?.setAttribute('hidden', '');
-}
-
function ArrowRightIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -754,10 +730,6 @@ export default function Home() {
const architectureEngineRef = useRef(null);
const architectureSourceRefs = useRef([]);
const architectureTargetRefs = useRef([]);
- const motionFrameRef = useRef(null);
- const [motionDemoReady, setMotionDemoReady] = useState(false);
- const [motionPlaying, setMotionPlaying] = useState(true);
- const [motionFocus, setMotionFocus] = useState(-1);
const language = isBrowser
? (window.location.pathname.startsWith('/zh-CN/') ? 'zh-CN' : 'en')
@@ -770,48 +742,6 @@ export default function Home() {
const quickStartPath = useBaseUrl(localizePath(language,
`/docs/${version}/getting-started/locally/quick-start-seatunnel-engine`));
const connectorsPath = useBaseUrl(localizePath(language,
`/docs/${version}/connectors/source`));
const logoPath = `${assetRoot}image/logo.png`;
- const motionDemoPath =
`${useBaseUrl('/home/seatunnel-motion.html')}?locale=${language}`;
-
- const getMotionDemo = () =>
motionFrameRef.current?.contentWindow?.SeaTunnelMotion;
-
- const syncMotionDemoState = () => {
- const motionDemo = getMotionDemo();
- if (!motionDemo) {
- return;
- }
- setMotionPlaying(motionDemo.state.playing);
- setMotionFocus(motionDemo.state.focus);
- };
-
- const handleMotionDemoLoad = (event) => {
- prepareMotionDemo(event);
- setMotionDemoReady(true);
- syncMotionDemoState();
-
- const frameDocument = event.currentTarget.contentDocument;
- const scheduleMotionDemoStateSync = () =>
window.requestAnimationFrame(syncMotionDemoState);
- frameDocument?.addEventListener('click', scheduleMotionDemoStateSync,
true);
- frameDocument?.addEventListener('keydown',
scheduleMotionDemoStateSync, true);
- };
-
- const toggleMotionDemoPlayback = () => {
- const motionDemo = getMotionDemo();
- if (!motionDemo) {
- return;
- }
- motionDemo.state.playing = !motionDemo.state.playing;
- setMotionPlaying(motionDemo.state.playing);
- };
-
- const focusMotionDemo = (index) => {
- const motionDemo = getMotionDemo();
- if (!motionDemo) {
- return;
- }
- motionDemo.setFocus(index);
- setMotionFocus(index);
- };
-
useEffect(() => {
if (!isBrowser || !pageRef.current) {
return undefined;
@@ -1699,68 +1629,9 @@ export default function Home() {
<section className="st-home-section st-home-motion-section">
<div className="st-home-container">
- <p className="st-home-eyebrow
st-home-rv">{content.motion.eyebrow}</p>
<h2 className="st-home-section-title
st-home-rv">{content.motion.title}</h2>
<p className="st-home-section-lead
st-home-rv">{content.motion.lead}</p>
- <a
- href={motionDemoPath}
- className="st-home-motion-open st-home-rv"
- target="_blank"
- rel="noreferrer"
- >
- {content.motion.openButton} <ArrowRightIcon />
- </a>
- <div className="st-home-motion-controls st-home-rv"
role="group" aria-label={content.motion.controls.label}>
- <button
- type="button"
- className="st-home-motion-control"
- disabled={!motionDemoReady}
- onClick={toggleMotionDemoPlayback}
- >
- {motionPlaying ? content.motion.controls.pause :
content.motion.controls.play}
- </button>
- <button
- type="button"
- className={`st-home-motion-control${motionFocus
=== -1 ? ' is-active' : ''}`}
- disabled={!motionDemoReady}
- aria-pressed={motionFocus === -1}
- onClick={() => focusMotionDemo(-1)}
- >
- {content.motion.controls.overview}
- </button>
- {content.motion.mechanisms.map((mechanism, index) => (
- <button
- type="button"
-
className={`st-home-motion-control${motionFocus === index ? ' is-active' : ''}`}
- disabled={!motionDemoReady}
- aria-pressed={motionFocus === index}
- key={mechanism.title}
- onClick={() => focusMotionDemo(index)}
- >
- {mechanism.title}
- </button>
- ))}
- </div>
- <div className="st-home-motion-frame st-home-rv">
- <iframe
- ref={motionFrameRef}
- className="st-home-motion-iframe"
- src={motionDemoPath}
- title={content.motion.frameTitle}
- loading="lazy"
- allowFullScreen
- onLoad={handleMotionDemoLoad}
- ></iframe>
- </div>
- <ul className="st-home-motion-mobile-grid">
- {content.motion.mechanisms.map((mechanism, index) => (
- <li className="st-home-motion-mobile-card"
key={mechanism.title}>
- <span>{String(index + 1).padStart(2,
'0')}</span>
- <h3>{mechanism.title}</h3>
- <p>{mechanism.description}</p>
- </li>
- ))}
- </ul>
+ <MotionMechanismGrid {...content.motion} />
</div>
</section>
diff --git a/src/pages/home/index.less b/src/pages/home/index.less
index 4e780d50a804..e6b860ed8238 100644
--- a/src/pages/home/index.less
+++ b/src/pages/home/index.less
@@ -967,87 +967,261 @@ html[data-theme='light'] .st-home {
}
.st-home-motion-section {
+ --motion-card: rgba(16, 29, 57, 0.88);
+ --motion-stage: rgba(6, 15, 32, 0.62);
+ --motion-node: rgba(18, 34, 65, 0.96);
+ --motion-ink: #eef4ff;
+ --motion-muted: #a9bdd2;
+ --motion-line: rgba(166, 203, 232, 0.28);
+ --motion-soft: rgba(93, 184, 226, 0.14);
+ --motion-blue: #5db8e2;
+ --motion-teal: #52cbbf;
+ --motion-on-blue: #082137;
+ --motion-on-teal: #061f2a;
+ --motion-teal-text: #6ee5d7;
+ --motion-highlight: rgba(82, 203, 191, 0.14);
background: var(--bg1);
}
-.st-home-motion-open {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- margin-top: 1.5rem;
- color: var(--t1);
- font-size: 0.94rem;
- font-weight: 700;
- transition: color 0.2s ease, transform 0.2s ease;
+.st-home-motion-section .st-home-section-title {
+ max-width: 860px;
}
-.st-home-motion-open:hover {
- color: #7dd3fc;
- transform: translateX(3px);
+.st-home-motion-section .st-home-section-lead {
+ max-width: 760px;
}
-.st-home-motion-open svg {
- width: 1.125rem;
- height: 1.125rem;
+.st-home-motion-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1.15rem;
+ margin: 3rem 0 0;
+ padding: 0;
+ list-style: none;
}
-.st-home-motion-controls {
+.st-home-motion-card {
+ min-width: 0;
+ border: 1px solid var(--motion-line);
+ border-radius: 1.2rem;
+ background: var(--motion-card);
+ box-shadow: 0 16px 32px rgba(0, 0, 0, 0.16);
+}
+
+.st-home-motion-card-schema {
+ border-color: var(--motion-teal);
+}
+
+.st-home-motion-card article {
display: flex;
- flex-wrap: wrap;
- gap: 0.625rem;
- margin-top: 1.5rem;
+ min-height: 100%;
+ flex-direction: column;
+ padding: 1.25rem;
}
-.st-home-motion-control {
- padding: 0.5rem 0.75rem;
- border: 1px solid rgba(137, 161, 199, 0.26);
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.06);
- color: var(--t2);
- cursor: pointer;
- font: inherit;
- font-size: 0.82rem;
- font-weight: 600;
- line-height: 1.3;
- transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
+.st-home-motion-card-heading {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 0.7rem;
+ align-items: start;
}
-.st-home-motion-control:hover,
-.st-home-motion-control:focus-visible,
-.st-home-motion-control.is-active {
- border-color: rgba(107, 198, 242, 0.72);
- background: rgba(93, 184, 226, 0.16);
- color: var(--t1);
- outline: none;
+.st-home-motion-card-heading > span {
+ display: grid;
+ width: 2rem;
+ height: 2rem;
+ place-items: center;
+ border-radius: 0.6rem;
+ background: var(--motion-soft);
+ color: var(--motion-blue);
+ font-family: var(--ifm-font-family-monospace);
+ font-size: 0.76rem;
+ font-weight: 700;
+}
+
+.st-home-motion-card h3,
+.st-home-motion-card-heading p,
+.st-home-motion-card-detail,
+.st-home-motion-card-note,
+.st-home-motion-caption {
+ margin: 0;
}
-.st-home-motion-control:disabled {
- cursor: wait;
- opacity: 0.55;
+.st-home-motion-card h3 {
+ color: var(--motion-ink);
+ font-size: 1.16rem;
+ font-weight: 750;
+ letter-spacing: -0.025em;
+ line-height: 1.2;
}
-.st-home-motion-frame {
- aspect-ratio: 16 / 9;
- margin-top: 3rem;
- padding: 0.5rem;
+.st-home-motion-card-heading p {
+ margin-top: 0.28rem;
+ color: var(--motion-muted);
+ font-size: 0.86rem;
+ line-height: 1.45;
+}
+
+.st-home-motion-visual {
+ display: flex;
+ align-items: center;
+ min-height: 10.35rem;
+ margin: 1rem 0 0.85rem;
+ padding: 0.45rem;
overflow: hidden;
- border: 1px solid rgba(108, 177, 235, 0.24);
- border-radius: 1.75rem;
- background: #e9f0f4;
- box-shadow: 0 28px 64px rgba(2, 8, 19, 0.3);
+ border-radius: 0.8rem;
+ background: var(--motion-stage);
}
-.st-home-motion-iframe {
+.st-home-motion-diagram {
display: block;
width: 100%;
- height: 100%;
- border: 0;
- border-radius: 1.3rem;
- background: #e9f0f4;
+ height: auto;
}
-.st-home-motion-mobile-grid {
- display: none;
+.st-home-motion-diagram text {
+ font-family: var(--ifm-font-family-base);
+}
+
+.st-home-motion-diagram-label {
+ fill: var(--motion-muted);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.st-home-motion-diagram-copy,
+.st-home-motion-diagram-strong {
+ fill: var(--motion-ink);
+ font-size: 13px;
+ font-weight: 550;
+}
+
+.st-home-motion-diagram-strong {
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.st-home-motion-diagram-muted-copy {
+ fill: var(--motion-muted);
+ font-size: 12px;
+}
+
+.st-home-motion-diagram-code {
+ fill: var(--motion-muted);
+ font-family: var(--ifm-font-family-monospace) !important;
+ font-size: 11px;
+}
+
+.st-home-motion-diagram-teal-copy {
+ fill: var(--motion-teal-text);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.st-home-motion-diagram-accent-text {
+ fill: var(--motion-blue);
+ font-size: 10px;
+ font-weight: 750;
+}
+
+.st-home-motion-diagram-on-blue,
+.st-home-motion-diagram-on-teal,
+.st-home-motion-diagram-chip-text {
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.st-home-motion-diagram-on-blue {
+ fill: var(--motion-on-blue);
+}
+
+.st-home-motion-diagram-on-teal {
+ fill: var(--motion-on-teal);
+}
+
+.st-home-motion-diagram-chip-text {
+ fill: var(--motion-blue);
+}
+
+.st-home-motion-diagram-sql {
+ font-size: 20px;
+}
+
+.st-home-motion-diagram-node {
+ fill: var(--motion-node);
+ stroke: var(--motion-line);
+ stroke-width: 1.2;
+}
+
+.st-home-motion-diagram-line,
+.st-home-motion-diagram-arrow {
+ stroke: var(--motion-line);
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.6;
+}
+
+.st-home-motion-diagram-soft,
+.st-home-motion-diagram-chip {
+ fill: var(--motion-soft);
+}
+
+.st-home-motion-diagram-packet,
+.st-home-motion-diagram-blue {
+ fill: var(--motion-blue);
+}
+
+.st-home-motion-diagram-teal {
+ fill: var(--motion-teal);
+}
+
+.st-home-motion-diagram-blue-fill {
+ fill: var(--motion-blue);
+}
+
+.st-home-motion-diagram-teal-fill {
+ fill: var(--motion-teal);
+}
+
+.st-home-motion-diagram-table {
+ fill: none;
+ stroke: var(--motion-blue);
+ stroke-width: 1.4;
+}
+
+.st-home-motion-diagram-highlight {
+ fill: var(--motion-highlight);
+}
+
+.st-home-motion-card-detail {
+ color: var(--motion-blue);
+ font-size: 0.82rem;
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.st-home-motion-card-detail::before {
+ display: inline-block;
+ margin-right: 0.45rem;
+ color: var(--motion-teal);
+ content: '•';
+}
+
+.st-home-motion-card-note {
+ margin-top: 0.55rem;
+ padding-top: 0.55rem;
+ border-top: 1px solid var(--motion-line);
+ color: var(--motion-muted);
+ font-size: 0.75rem;
+ line-height: 1.4;
+}
+
+.st-home-motion-caption {
+ max-width: 760px;
+ margin-top: 1.25rem;
+ color: var(--motion-muted);
+ font-size: 0.8rem;
+ line-height: 1.5;
}
.st-home-architecture .st-home-gradient {
@@ -2523,34 +2697,24 @@ html[data-theme='light'] .st-home
.st-home-features-section {
}
html[data-theme='light'] .st-home .st-home-motion-section {
+ --motion-card: rgba(255, 255, 255, 0.88);
+ --motion-stage: #f4f8fb;
+ --motion-node: #fff;
+ --motion-ink: #17374b;
+ --motion-muted: #526c7c;
+ --motion-line: #d7e5eb;
+ --motion-soft: #eaf6f7;
+ --motion-blue: #2469a6;
+ --motion-teal: #30adb2;
+ --motion-on-blue: #fff;
+ --motion-on-teal: #0d3945;
+ --motion-teal-text: #07777b;
+ --motion-highlight: #dff3f1;
background: linear-gradient(180deg, #e7eef8 0%, #f4f8fd 100%);
}
-html[data-theme='light'] .st-home .st-home-motion-open {
- color: #315a83;
-}
-
-html[data-theme='light'] .st-home .st-home-motion-open:hover {
- color: #1d78a8;
-}
-
-html[data-theme='light'] .st-home .st-home-motion-control {
- border-color: rgba(90, 150, 230, 0.2);
- background: rgba(255, 255, 255, 0.72);
- color: #4e6886;
-}
-
-html[data-theme='light'] .st-home .st-home-motion-control:hover,
-html[data-theme='light'] .st-home .st-home-motion-control:focus-visible,
-html[data-theme='light'] .st-home .st-home-motion-control.is-active {
- border-color: rgba(72, 153, 211, 0.58);
- background: rgba(216, 239, 253, 0.88);
- color: #254f78;
-}
-
-html[data-theme='light'] .st-home .st-home-motion-frame {
- border-color: rgba(90, 150, 230, 0.26);
- box-shadow: 0 28px 58px rgba(87, 122, 177, 0.18);
+html[data-theme='light'] .st-home .st-home-motion-card {
+ box-shadow: 0 14px 30px rgba(87, 122, 177, 0.1);
}
html[data-theme='light'] .st-home .st-home-architecture {
@@ -2841,71 +3005,23 @@ html[data-theme='light'] .st-home .st-home-rv {
padding: 5.5rem 1rem;
}
- .st-home-motion-frame {
- margin-top: 2.25rem;
- padding: 0.35rem;
- border-radius: 1.1rem;
- }
-
- .st-home-motion-iframe {
- border-radius: 0.8rem;
- }
-
- .st-home-motion-open,
- .st-home-motion-controls,
- .st-home-motion-frame {
- display: none;
- }
-
- .st-home-motion-mobile-grid {
+ .st-home-motion-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.875rem;
margin: 2.25rem 0 0;
- padding: 0;
- list-style: none;
}
- .st-home-motion-mobile-card {
- min-height: 9.5rem;
+ .st-home-motion-card article {
padding: 1rem;
- border: 1px solid rgba(137, 161, 199, 0.18);
- border-radius: 1rem;
- background: rgba(255, 255, 255, 0.05);
- }
-
- .st-home-motion-mobile-card span {
- display: block;
- margin-bottom: 0.65rem;
- color: #69c2ea;
- font-family: var(--ifm-font-family-monospace);
- font-size: 0.72rem;
- font-weight: 700;
- letter-spacing: 0.1em;
- }
-
- .st-home-motion-mobile-card h3 {
- margin: 0;
- color: var(--t1);
- font-size: 0.95rem;
- line-height: 1.35;
}
- .st-home-motion-mobile-card p {
- margin: 0.5rem 0 0;
- color: var(--t2);
- font-size: 0.8rem;
- line-height: 1.5;
- }
-
- html[data-theme='light'] .st-home .st-home-motion-mobile-card {
- border-color: rgba(90, 150, 230, 0.16);
- background: rgba(255, 255, 255, 0.78);
- box-shadow: 0 14px 30px rgba(87, 122, 177, 0.1);
+ .st-home-motion-visual {
+ min-height: auto;
}
- html[data-theme='light'] .st-home .st-home-motion-mobile-card span {
- color: #4f9bc6;
+ .st-home-motion-card h3 {
+ font-size: 1.06rem;
}
.st-home-cta-section {
@@ -2930,7 +3046,7 @@ html[data-theme='light'] .st-home .st-home-rv {
grid-template-columns: 1fr;
}
- .st-home-motion-mobile-grid {
+ .st-home-motion-grid {
grid-template-columns: 1fr;
}
}
diff --git a/static/home/seatunnel-motion.html
b/static/home/seatunnel-motion.html
deleted file mode 100644
index 5c9050596062..000000000000
--- a/static/home/seatunnel-motion.html
+++ /dev/null
@@ -1,329 +0,0 @@
-<!--
- Licensed to the Apache Software Foundation (ASF) under one or more
- contributor license agreements. See the NOTICE file distributed with
- this work for additional information regarding copyright ownership.
- The ASF licenses this file to You under the Apache License, Version 2.0
- (the "License"); you may not use this file except in compliance with
- the License. You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<!doctype html><html lang="en"><head><meta charset="utf-8"><meta
name="viewport" content="width=device-width,initial-scale=1"><meta
name="color-scheme" content="light"><title>Apache SeaTunnel · Data integration
in motion</title><style>
-*{box-sizing:border-box}body{margin:0;background:#e9f0f4;color:#17374b;font-family:Inter,Arial,"PingFang
SC","Microsoft
YaHei",sans-serif}main{max-width:1920px;margin:auto;padding:20px}#player{background:#f4f8fa;border:1px
solid #d5e3e9;border-radius:14px;overflow:hidden;box-shadow:0 16px 60px
#16364714}#film{display:block;width:100%;height:auto;aspect-ratio:16/9;cursor:zoom-in}#player:fullscreen{width:100vw;height:100vh;display:flex;align-items:center;border:none;background:#f4f8fa}#pla
[...]
-
-</style></head><body><main><section id="player" aria-label="SeaTunnel motion
player"><canvas id="film" width="1920" height="1080" tabindex="0" role="img"
aria-label="Apache SeaTunnel six-panel motion graphic: parallel reads, snapshot
and CDC, row transforms, multi-table routing, schema evolution, and checkpoint
recovery. Select any panel to enlarge it."></canvas></section><nav
aria-label="Playback controls"><button id="play">Pause</button><button
id="restart">Restart</button><button id=" [...]
- * Original vector animation; every frame is determined by time t and supports
offline playback, seeking, and frame export.
- * Technical illustration, not runtime metrics. The six panels are independent
examples, not six sequential job stages.
- * Uses system fonts only; no font files are bundled or downloaded.
- */
-'use strict';
-const W = 1920, H = 1080, DURATION = 24, PW = 584, PH = 326;
-const C = {ink:'#17374B',muted:'#526C7C',subtle:'#75909E',line:'#D7E5EB',
-
blue:'#2469A6',teal:'#30ADB2',dark:'#215F91',soft:'#EAF6F7',blueSoft:'#EDF3FA',
- paper:'#FFFFFF',bg:'#F4F8FA',stage:'#F7FAFC'};
-const TITLES = ['Parallel batch reads','Snapshot + CDC','Row-level
transforms','Multi-table routing','Schema evolution','Checkpoint recovery'];
-const SUBTITLES = ['Divide the work. Read splits in parallel.','Snapshot
first. Then capture binlog changes.','Compute fields and filter individual
rows.','Keep each record on the right table path.','Propagate supported table
changes.','Restart from a completed checkpoint.'];
-const TAGS = ['BATCH','CDC','TRANSFORM','MULTI-TABLE','DDL','RECOVERY'];
-const NOTES = ['JDBC example · split assignment is source-specific.',
- 'MySQL-CDC example · startup.mode = initial',
- 'SQL example · expression + row filter',
- 'Logical routing · physical connections may differ.',
- 'Zeta example · MySQL-CDC → JDBC MySQL',
- 'Exactly-once requires compatible source / sink support.'];
-const CAPTIONS = [
- ['PARALLELISM','Split the input, not the meaning.','Independent readers
process assigned source splits.'],
- ['CHANGE CAPTURE','Bootstrap once. Keep the changes moving.','The example
includes INSERT, UPDATE and DELETE.'],
- ['ROW TRANSFORMATION','Shape every record before it lands.','A field
expression and a filter operate on individual rows.'],
- ['TABLE IDENTITY','One job. Distinct table destinations.','The table identity
determines the matching sink writer.'],
- ['SCHEMA CHANGE','A table changes. The supported path adapts.','DDL is
applied before the example sends a row with the new column.'],
- ['RECOVERY','A failure is not a reason to start from zero.','Restore
completed checkpoint state; source history must still be available.']
-];
-const canvas = document.getElementById('film'), ctx =
canvas.getContext('2d',{alpha:false});
-const logo = new Image(); logo.src = LOGO_DATA;
-const MOTION_LOCALE = 'en';
-const UI = {
- title: 'Apache SeaTunnel · Data integration in motion', player: 'SeaTunnel
motion player',
- canvas: 'Apache SeaTunnel animated overview: parallel reads, snapshot and
CDC, row transforms, multi-table routing, schema evolution, and checkpoint
recovery. Click or use number keys to focus a card.',
- controls: 'Playback controls', pause: 'Pause', play: 'Play', restart:
'Restart', overview: 'All mechanisms', fullscreen: 'Fullscreen', speed:
'Speed', timeline: 'Playback progress',
- hint: 'Click a card to focus it; click again to return to the overview.
Space: pause / play; R: restart; 1–6: focus. This offline animation does not
connect to a database.',
- fullscreenUnavailable: 'Fullscreen is unavailable in this browser; playback
is still available.', assetLoadFailed: 'Unable to load animation assets: '
-};
-document.documentElement.lang = MOTION_LOCALE;
-document.title = UI.title;
-document.getElementById('player').setAttribute('aria-label', UI.player);
-canvas.setAttribute('aria-label', UI.canvas);
-document.querySelector('nav').setAttribute('aria-label', UI.controls);
-document.getElementById('play').textContent = UI.pause;
-document.getElementById('restart').textContent = UI.restart;
-document.getElementById('back').textContent = UI.overview;
-document.getElementById('fullscreen').textContent = UI.fullscreen;
-document.getElementById('speed').setAttribute('aria-label', UI.speed);
-document.getElementById('timeline').setAttribute('aria-label', UI.timeline);
-const speedLabel = document.getElementById('speed').closest('label');
-if (speedLabel?.firstChild) speedLabel.firstChild.textContent = UI.speed + ' ';
-document.getElementById('hint').textContent = UI.hint;
-const IS_EXPORT=Boolean(window.__MOTION_EXPORT__) || new
URLSearchParams(location.search).has('export');
-const state =
{time:0,playing:!IS_EXPORT&&!window.matchMedia('(prefers-reduced-motion:
reduce)').matches,speed:1,focus:-1,last:0,raf:0};
-const telemetry = {errors:[],textOverflow:[],ready:false};
-let staticCanvas=null,staticKey='';
-function clamp(n,a=0,b=1){return Math.min(b,Math.max(a,n));}
-function smooth(n){n=clamp(n);return n*n*(3-2*n);}
-function lerp(a,b,p){return a+(b-a)*p;}
-function phase(t){return ((t%8)+8)%8;}
-function cycleOpacity(q){return smooth(q/.25)*(1-smooth((q-7.55)/.4));}
-function rr(g,x,y,w,h,r=10,fill=C.paper,stroke=null,lw=1.5){
-
if(w<=0||h<=0)return;g.beginPath();g.roundRect(x,y,w,h,r);if(fill){g.fillStyle=fill;g.fill();}if(stroke){g.strokeStyle=stroke;g.lineWidth=lw;g.stroke();}
-}
-function
line(g,x1,y1,x2,y2,color=C.line,lw=2,dash=[]){g.beginPath();g.setLineDash(dash);g.moveTo(x1,y1);g.lineTo(x2,y2);g.strokeStyle=color;g.lineWidth=lw;g.stroke();g.setLineDash([]);}
-function
txt(g,s,x,y,size=18,color=C.ink,weight=400,align='left',max=null,mono=false){
- g.font=`${weight} ${size}px ${mono?'"DejaVu Sans
Mono",Consolas,monospace':'"Inter Display",Inter,Arial,sans-serif'}`;
- g.textAlign=align;g.textBaseline='alphabetic';g.fillStyle=color;
- const width=g.measureText(s).width;
- if(max&&width>max+.6){const key=s+' @
'+size;if(!telemetry.textOverflow.includes(key))telemetry.textOverflow.push(key);}
- g.fillText(s,x,y);
-}
-function
circle(g,x,y,r,fill,stroke=null,lw=1.5){g.beginPath();g.arc(x,y,r,0,Math.PI*2);if(fill){g.fillStyle=fill;g.fill();}if(stroke){g.strokeStyle=stroke;g.lineWidth=lw;g.stroke();}}
-function
check(g,x,y,s=1,col=C.teal){g.beginPath();g.moveTo(x-5*s,y);g.lineTo(x-1*s,y+4*s);g.lineTo(x+7*s,y-5*s);g.lineCap='round';g.lineJoin='round';g.lineWidth=2.4*s;g.strokeStyle=col;g.stroke();}
-function
arrowHead(g,x,y,angle=0,col=C.subtle,size=5){g.save();g.translate(x,y);g.rotate(angle);g.beginPath();g.moveTo(-size,-size*.72);g.lineTo(0,0);g.lineTo(-size,size*.72);g.strokeStyle=col;g.lineWidth=1.8;g.stroke();g.restore();}
-function bezierPoint(p,a){const u=1-p;return
{x:u*u*u*a[0]+3*u*u*p*a[2]+3*u*p*p*a[4]+p*p*p*a[6],y:u*u*u*a[1]+3*u*u*p*a[3]+3*u*p*p*a[5]+p*p*p*a[7]};}
-function
curve(g,a,col=C.line,lw=2,dash=[]){g.beginPath();g.moveTo(a[0],a[1]);g.bezierCurveTo(a[2],a[3],a[4],a[5],a[6],a[7]);g.lineWidth=lw;g.strokeStyle=col;g.setLineDash(dash);g.stroke();g.setLineDash([]);}
-function packet(g,a,p,label='',col=C.blue,w=39){
- if(p<0||p>1)return;const d=bezierPoint(p,a);g.save();
- if(!label){circle(g,d.x,d.y,7,col);circle(g,d.x,d.y,3,C.paper);}
-
else{rr(g,d.x-w/2,d.y-12,w,24,7,col);txt(g,label,d.x,d.y+5,13.5,C.paper,650,'center',w-5);}
- g.restore();
-}
-function
pill(g,label,x,y,w,col=C.blue,fill=C.blueSoft,size=14){rr(g,x,y,w,26,13,fill);txt(g,label,x+w/2,y+18,size,col,650,'center',w-12);}
-function statusLine(g,label,kind='blue'){
- const color=kind==='teal'?C.teal:C.blue;
- circle(g,38,281,3.7,color);txt(g,label,49,287,16,color,600,'left',504);
-}
-function dbIcon(g,x,y,w=62,h=75,col=C.blue){
- const
eh=12;g.beginPath();g.moveTo(x,y+eh/2);g.lineTo(x,y+h-eh/2);g.ellipse(x+w/2,y+h-eh/2,w/2,eh/2,0,Math.PI,0,true);g.lineTo(x+w,y+eh/2);g.fillStyle=C.blueSoft;g.fill();g.strokeStyle=col;g.lineWidth=2;g.stroke();
-
g.beginPath();g.ellipse(x+w/2,y+eh/2,w/2,eh/2,0,0,Math.PI*2);g.fillStyle=C.paper;g.fill();g.stroke();
- for(let
i=1;i<=2;i++){g.beginPath();g.ellipse(x+w/2,y+eh/2+(h-eh)*i/3,w/2,eh/2,0,0,Math.PI);g.stroke();}
-}
-function
tableIcon(g,x,y,col=C.blue){rr(g,x,y,20,17,3,null,col,1.4);line(g,x,y+6,x+20,y+6,col,1.2);line(g,x+7,y+6,x+7,y+17,col,1.2);line(g,x+13,y+6,x+13,y+17,col,1.2);}
-const CARD_POS =
Array.from({length:6},(_,i)=>[64+(i%3)*604,251+Math.floor(i/3)*350]);
-function activeIndex(t){return Math.floor(((t%DURATION)+DURATION)%DURATION/4);}
-function drawBrand(g){
- // The repository logo is cropped only to remove fully transparent padding;
it is neither redrawn nor recolored.
- if(logo.complete&&logo.naturalWidth)g.drawImage(logo,1535,45,77,96);
-
txt(g,'Apache',1628,79,21,C.muted,500);txt(g,'SeaTunnel',1626,122,43,C.ink,700,'left',239);
-}
-function drawHeader(g,focus=-1){
- txt(g,'APACHE SEATUNNEL / ENGINEERING IN MOTION',66,58,17,C.blue,650);
- txt(g,focus<0?'Data integration, in
motion.':TITLES[focus],62,133,66,C.ink,650,'left',1436);
- txt(g,focus<0?'Six mechanisms behind the data integration
layer.':SUBTITLES[focus],66,182,25,C.muted,400,'left',1420);
- drawBrand(g);line(g,64,217,1856,217,C.line,1.5);
-}
-function drawBasePanel(g,i){
- // Shadows and information layers are intentionally restrained and cached
separately from the internal data animation.
-
g.save();g.shadowColor='#244D6910';g.shadowBlur=15;g.shadowOffsetY=4;rr(g,0,0,PW,PH,18,C.paper);g.restore();rr(g,0,0,PW,PH,18,null,C.line,1.4);
-
rr(g,24,24,34,34,10,C.soft);txt(g,String(i+1).padStart(2,'0'),41,47,17,C.blue,700,'center');
- txt(g,TITLES[i],71,49,27,C.ink,650,'left',421);
- txt(g,SUBTITLES[i],26,81,18.5,C.muted,400,'left',534);
- rr(g,22,103,PW-44,161,11,C.stage);
- line(g,26,300,PW-26,300,C.line,1);
- txt(g,NOTES[i],27,318,15,C.muted,450,'left',PW-54);
-}
-function staticBackground(focus){
- const key=`${canvas.width}x${canvas.height}:${focus}`;
- if(staticCanvas&&staticKey===key)return staticCanvas;
-
staticKey=key;staticCanvas=document.createElement('canvas');staticCanvas.width=canvas.width;staticCanvas.height=canvas.height;
- const
g=staticCanvas.getContext('2d',{alpha:false});g.scale(canvas.width/W,canvas.height/H);
-
g.fillStyle=C.bg;g.fillRect(0,0,W,H);g.fillStyle=C.paper;g.fillRect(0,0,W,218);drawHeader(g,focus);
- if(focus<0){for(let
i=0;i<6;i++){g.save();g.translate(...CARD_POS[i]);drawBasePanel(g,i);g.restore();}}
- else{const
sc=2.03;g.save();g.translate(75,269);g.scale(sc,sc);drawBasePanel(g,focus);g.restore();}
- line(g,64,955,1856,955,C.line,1.5);
- txt(g,'Apache SeaTunnel is an open-source, distributed data integration
platform.',65,995,22,C.ink,500,'left',1780);
- txt(g,'Independent conceptual examples. Capabilities and delivery guarantees
depend on connectors, engine and
configuration.',65,1034,17.5,C.muted,400,'left',1660);
- txt(g,'24s LOOP',1854,1034,15,C.blue,650,'right');
- return staticCanvas;
-}
-function javaHash(s){let n=0;for(const ch of
s)n=(Math.imul(31,n)+ch.charCodeAt(0))|0;return n;}
-const
splitRecords=Array.from({length:6},(_,i)=>({id:'S'+(i+1),owner:(javaHash('split-'+i)&2147483647)%3,start:.35+i*.62}));
-function drawParallel(g,q){
- txt(g,'SOURCE
SPLITS',37,124,12.5,C.muted,650);txt(g,'READERS',230,124,12.5,C.muted,650);txt(g,'TARGET',464,124,12.5,C.muted,650);
- const ys=[152,190,228],colors=[C.blue,C.teal,C.dark];
- for(let r=0;r<3;r++){
- curve(g,[150,189,177,189,186,ys[r],218,ys[r]],C.line,2);
- const
out=[343,ys[r],385,ys[r],405,190,456,190];curve(g,out,C.line,2);arrowHead(g,452,190,0,C.subtle,4);
-
rr(g,219,ys[r]-14,123,28,8,C.paper,colors[r],1.4);circle(g,235,ys[r],3.3,colors[r]);txt(g,'Reader
'+(r+1),248,ys[r]+5,16,C.ink,500);
- }
- dbIcon(g,464,160,59,58);rr(g,34,133,111,119,9,C.paper,C.line,1);
- let done=0;
- for(let i=0;i<6;i++){
- const
s=splitRecords[i],x=43+(i%2)*49,y=146+Math.floor(i/2)*35,p=(q-s.start)/1.25,arrive=s.start+2.5;
- const waiting=q<s.start,finished=q>=arrive;
- rr(g,x,y,41,26,6,waiting?C.blueSoft:C.stage,waiting?C.line:null,1);
- txt(g,s.id,x+20.5,y+18,14,waiting?colors[s.owner]:C.subtle,650,'center');
- const a=[150,189,177,189,186,ys[s.owner],218,ys[s.owner]];
- const b=[343,ys[s.owner],385,ys[s.owner],405,190,456,190];
-
packet(g,a,p,s.id,colors[s.owner]);packet(g,b,(q-s.start-1.25)/1.25,s.id,colors[s.owner]);
- if(finished)done++;
- }
- pill(g,`${done} / 6`,458,227,72,C.blue,C.blueSoft,13.5);
- statusLine(g,done===6?'6 illustrated splits delivered.':'Assigned splits move
through 3 readers.',done===6?'teal':'blue');
-}
-const CDC_EVENTS=[
- {start:.4,arrive:1.3,op:'S',id:41,value:'NEW'},
- {start:1.45,arrive:2.35,op:'S',id:42,value:'NEW'},
- {start:3.0,arrive:3.9,op:'I',id:43,value:'NEW'},
- {start:4.2,arrive:5.1,op:'U',id:42,value:'PAID'},
- {start:5.55,arrive:6.45,op:'D',id:41,value:null}
-];
-function cdcModel(q){
- const source=new Map([[41,'NEW'],[42,'NEW']]),target=new Map();
- for(const e of
CDC_EVENTS){if(e.op!=='S'&&q>=e.start){if(e.op==='D')source.delete(e.id);else
source.set(e.id,e.value);}if(q>=e.arrive){if(e.op==='D')target.delete(e.id);else
target.set(e.id,e.value);}}
- return {source:[...source],target:[...target]};
-}
-function recordsTable(g,x,y,w,label,rows){
- txt(g,label,x,y-9,14,C.muted,600);rr(g,x,y,w,108,9,C.paper,C.line,1.2);
-
rr(g,x+1,y+1,w-2,25,7,C.blueSoft);txt(g,'id',x+12,y+18,13.5,C.muted,600);txt(g,'status',x+58,y+18,13.5,C.muted,600);
- for(let i=0;i<rows.length;i++){const
[id,value]=rows[i],yy=y+45+i*25;if(value==='PAID')rr(g,x+5,yy-17,w-10,23,5,C.soft);txt(g,''+id,x+12,yy,15,C.ink,500,'left',40,true);txt(g,value,x+58,yy,14.5,value==='PAID'?C.teal:C.ink,value==='PAID'?650:450);}
- if(!rows.length)txt(g,'waiting…',x+12,y+56,14,C.subtle,400);
-}
-function drawCDC(g,q){
- const m=cdcModel(q);recordsTable(g,33,137,148,'MySQL /
orders',m.source);recordsTable(g,396,137,154,'Target / orders',m.target);
- const
a=[208,187,254,187,321,187,367,187];line(g,191,187,386,187,C.line,2.2);arrowHead(g,385,187,0,C.subtle,6);
- for(const e of
CDC_EVENTS)packet(g,a,(q-e.start)/(e.arrive-e.start),e.op+':'+e.id,e.op==='I'||e.op==='S'?C.teal:C.blue,53);
-
pill(g,q<2.8?'SNAPSHOT':'BINLOG',235,124,108,q<2.8?C.blue:C.teal,q<2.8?C.blueSoft:C.soft,13);
- txt(g,'SeaTunnel',291,223,14.5,C.muted,500,'center');
- statusLine(g,q<2.8?'Read existing rows.':q<4.1?'INSERT · new row
43':q<5.55?'UPDATE · row 42 becomes PAID':q<6.45?'DELETE · remove row
41':'DELETE · row 41 is removed.',q>=6.45?'teal':'blue');
-}
-const
TRANSFORMS=[{id:1,name:'Ada',start:.45},{id:0,name:'Temp',start:2.35},{id:2,name:'Lin',start:4.25}];
-function transformModel(q){return
TRANSFORMS.filter(r=>r.id>0&&q>=r.start+1.65).map(r=>[r.id,r.name+'_']);}
-function drawTransform(g,q){
- txt(g,'INPUT',36,124,13,C.muted,650);txt(g,'ROW
SQL',241,124,13,C.muted,650);txt(g,'OUTPUT',440,124,13,C.muted,650);
- const
a=[168,173,190,173,204,173,228,173],b=[347,173,367,173,396,173,419,173];curve(g,a);curve(g,b);arrowHead(g,418,173,0,C.subtle);
-
rr(g,33,137,132,93,9,C.paper,C.line,1.2);rr(g,422,137,128,93,9,C.paper,C.line,1.2);
- for(let i=0;i<3;i++){const r=TRANSFORMS[i];txt(g,`${r.id}
${r.name}`,44,159+27*i,15.5,r.id===0?C.muted:C.ink,450,'left',111,true);}
- rr(g,231,146,113,57,12,C.blue);txt(g,'Sql',287.5,182,25,C.paper,650,'center');
- let filtered=false;
- for(const r of TRANSFORMS){
- packet(g,a,(q-r.start)/.65,`${r.id}`,C.blue,28);
- if(r.id>0)packet(g,b,(q-r.start-.85)/.8,`${r.id}`,C.teal,28);
- else if(q>=r.start+.65){filtered=true;const
p=(q-r.start-.65)/.7;packet(g,[287.5,205,287.5,225,287.5,234,287.5,247],p,'0',C.dark,28);}
- }
- const out=transformModel(q);out.forEach((r,i)=>txt(g,`${r[0]}
${r[1]}`,433,162+i*29,15,C.teal,600,'left',106,true));
- if(!out.length)txt(g,'waiting…',434,169,14,C.subtle);
-
if(filtered){circle(g,287,239,8,C.paper,C.blue);line(g,282,244,292,234,C.blue,1.7);txt(g,'id
= 0 filtered',327,244,12.5,C.muted,450);}
- else{txt(g,'CONCAT(name,\'_\')',32,252,13.5,C.muted,450,'left',245,true);}
- statusLine(g,'WHERE id > 0 · CONCAT(name, \'_\')');
-}
-function drawRouting(g,q){
- const
tables=['users','orders','items'],colors=[C.blue,C.teal,C.dark],ys=[143,192,241];
- for(let i=0;i<3;i++){
- const
y=ys[i],a=[168,y,204,y,205,192,247,192],b=[335,192,375,192,381,y,421,y];curve(g,a,C.line,1.7);curve(g,b,C.line,1.7);arrowHead(g,417,y,0,C.subtle,4);
-
rr(g,32,y-17,135,34,8,C.paper,C.line,1.2);tableIcon(g,43,y-9,colors[i]);txt(g,tables[i],73,y+5,16,C.ink,500,'left',81);
-
rr(g,423,y-17,130,34,8,C.paper,C.line,1.2);tableIcon(g,434,y-9,colors[i]);txt(g,tables[i],463,y+5,15.5,C.ink,500,'left',82);
- for(let j=0;j<2;j++){const
start=.35+i*.5+j*3.0;packet(g,a,(q-start)/1.15,tables[i][0].toUpperCase(),colors[i],25);packet(g,b,(q-start-1.15)/1.15,tables[i][0].toUpperCase(),colors[i],25);}
- }
-
rr(g,247,151,88,82,13,C.blue);txt(g,'Table',291,180,18,C.paper,650,'center');txt(g,'route',291,204,18,C.paper,650,'center');
- statusLine(g,'Table ID → matching destination writer.');
-}
-function schemaModel(q){return
{sourceColumns:q>=1.1?3:2,sinkColumns:q>=3.8?3:2,newRowSent:q>=5.0,newRowArrived:q>=6.1};}
-function schemaTable(g,x,y,w,label,cols,added){
-
txt(g,label,x,y-11,14,C.muted,600,'left',w+20);rr(g,x,y,w,110,9,C.paper,C.line,1.2);
-
rr(g,x+1,y+1,w-2,25,7,C.blueSoft);tableIcon(g,x+10,y+5,C.blue);txt(g,'customers',x+39,y+19,13.5,C.ink,600,'left',w-43);
- const names=['id BIGINT','name VARCHAR','region VARCHAR'];
- for(let
i=0;i<cols;i++){if(i===2)rr(g,x+5,y+81,w-10,24,5,C.soft);txt(g,names[i],x+11,y+46+i*26,12.2,i===2?C.teal:C.muted,i===2?650:400,'left',w-16,true);}
- if(added)check(g,x+w-12,y-14,.65,C.teal);
-}
-function drawSchema(g,q){
- const m=schemaModel(q);schemaTable(g,32,138,177,'MySQL
source',m.sourceColumns,m.sourceColumns===3);schemaTable(g,376,138,177,'JDBC
MySQL sink',m.sinkColumns,m.sinkColumns===3);
- const
path=[232,190,266,190,321,190,354,190];curve(g,[219,190,266,190,321,190,366,190],C.line,2);arrowHead(g,365,190,0,C.subtle,5);
- pill(g,'DDL',264,126,60,C.blue,C.blueSoft,13);
- packet(g,path,(q-1.4)/2.4,'ADD',C.blue,43);
- if(q>=5){packet(g,path,(q-5)/1.1,'ROW',C.teal,44);}
- if(q>3.8)txt(g,'applied',294,237,13,C.teal,600,'center');else
txt(g,'region',294,236,13,C.muted,500,'center');
- statusLine(g,'schema-changes.enabled = true',m.sinkColumns===3?'teal':'blue');
-}
-function checkpointModel(q){return
{completed:q>=2.7,failed:q>=3.65&&q<5.55,restoring:q>=4.65&&q<5.55,resumed:q>=5.55};}
-function drawCheckpoint(g,q){
- const m=checkpointModel(q),lost=m.failed&&!m.restoring;
- const xs=[34,229,430],ww=[121,129,122];
-
line(g,156,161,228,161,C.line,2);arrowHead(g,222,161,0,C.subtle,5);line(g,359,161,429,161,C.line,2);arrowHead(g,424,161,0,C.subtle,5);
- for(let i=0;i<3;i++){
- const
failed=i===1&&lost;rr(g,xs[i],138,ww[i],47,10,failed?C.ink:C.paper,failed?C.ink:C.line,1.5);
- txt(g,failed?'Worker
lost':['Source','Writer','Sink'][i],xs[i]+ww[i]/2,168,failed?15:18,failed?C.paper:C.ink,600,'center',ww[i]-12);
- }
- if(q<1.1||(q>=5.55&&q<7.4)){
- const p=(q<1.1?q:q-5.55)/.9;for(let
j=0;j<2;j++){packet(g,[164,161,182,161,202,161,220,161],(p-j*.48)%1,'',C.teal);packet(g,[367,161,381,161,402,161,419,161],(p-j*.48+.2)%1,'',C.teal);}
- }
- // Simplified illustration of distributed barrier propagation and completion;
it does not model a complete two-phase commit protocol.
- if(q>=1.1&&q<2.7){const
x=lerp(93,488,(q-1.1)/1.6);line(g,x,126,x,196,C.blue,2.8,[4,3]);pill(g,'BARRIER',x-38,107,76,C.blue,C.blueSoft,11.5);}
- const store=[207,210,177,45];
-
curve(g,[94,187,94,217,152,232,207,232],m.restoring?C.blue:C.line,1.6,m.restoring?[4,3]:[]);
- curve(g,[294,187,294,197,294,200,294,210],C.line,1.6);
-
if(m.restoring){packet(g,[207,232,152,232,94,217,94,187],(q-4.65)/.9,'',C.blue);}
- rr(g,...store,9,m.completed?C.soft:C.paper,m.completed?C.teal:C.line,1.3);
- txt(g,m.completed?'Checkpoint #42':'Checkpoint
state',295.5,230,15,m.completed?C.dark:C.muted,600,'center');
-
txt(g,m.completed?'COMPLETED':'PENDING',295.5,247,11.5,m.completed?C.teal:C.subtle,650,'center');
- if(m.completed)check(g,367,227,.65,C.teal);
- let message=q<1.1?'Running · source and sink connected.':q<2.7?'Capture state
with a checkpoint barrier.':q<3.65?'Checkpoint #42 completed and
retained.':q<4.65?'A worker fails after checkpoint #42.':q<5.55?'Restore saved
source state from #42.':'Resumed · replay after the saved position.';
- statusLine(g,message,m.resumed?'teal':'blue');
-}
-const
DRAWS=[drawParallel,drawCDC,drawTransform,drawRouting,drawSchema,drawCheckpoint];
-function drawDynamicPanel(g,i,t){
- let q=phase(t);
- g.save();g.beginPath();g.rect(23,104,PW-46,192);g.clip();
- // At the end of the demo loop, the final and initial states transition
smoothly; the first frame always shows the complete illustration.
- if(q<7.5){DRAWS[i](g,q);}else{const
blend=smooth((q-7.5)/.5);g.globalAlpha=1-blend;DRAWS[i](g,7.49);g.globalAlpha=blend;DRAWS[i](g,0);}
- g.restore();
-}
-function drawFocusNotes(g,i,t){
- const x=1344,q=phase(t),copy=CAPTIONS[i];
- pill(g,copy[0],x,299,420,C.blue,C.blueSoft,15);
- const
lines=wrap(copy[1],22);lines.forEach((s,j)=>txt(g,s,x,391+j*45,35,C.ink,600,'left',440));
- const
lines2=wrap(copy[2],36);lines2.forEach((s,j)=>txt(g,s,x,554+j*31,22,C.muted,450,'left',446));
- txt(g,'INDEPENDENT MECHANISM',x,773,14,C.blue,650);
- txt(g,`${String(i+1).padStart(2,'0')} / 06`,x,833,41,C.ink,500);
- line(g,x,860,x+420,860,C.line,4);line(g,x,860,x+420*(q/8),860,C.teal,4);
-}
-function wrap(s,n){const out=[];let l='';for(const w of s.split(' ')){if((l+'
'+w).trim().length>n&&l){out.push(l);l=w;}else l+=(l?'
':'')+w;}if(l)out.push(l);return out;}
-function renderAt(t){
- if(!Number.isFinite(t))throw new TypeError('time must be finite');
- state.time=((t%DURATION)+DURATION)%DURATION;
-
ctx.setTransform(1,0,0,1,0,0);ctx.drawImage(staticBackground(state.focus),0,0);ctx.setTransform(canvas.width/W,0,0,canvas.height/H,0,0);
- if(state.focus<0){
- const ai=activeIndex(state.time),local=state.time%4;
- for(let
i=0;i<6;i++){ctx.save();ctx.translate(...CARD_POS[i]);drawDynamicPanel(ctx,i,state.time);
- if(i===ai){const
a=.48+.42*Math.sin(Math.PI*local/4);ctx.globalAlpha=a;rr(ctx,0,0,PW,PH,18,null,C.teal,2);ctx.globalAlpha=1;rr(ctx,26,PH-1,532*clamp(local/4),3,1.5,C.teal);}
- ctx.restore();}
-
-
}else{ctx.save();ctx.translate(75,269);ctx.scale(2.03,2.03);drawDynamicPanel(ctx,state.focus,state.time);ctx.restore();drawFocusNotes(ctx,state.focus,state.time);}
- const
progress=document.getElementById('timeline');if(progress&&document.activeElement!==progress)progress.value=state.time;
-
document.getElementById('time').textContent=`${state.time.toFixed(1).padStart(4,'0')}
/ ${DURATION}s`;
- return state.time;
-}
-function setResolution(w,h){
-
if(!Number.isInteger(w)||!Number.isInteger(h)||w<320||w>7680||h<180||h>4320)throw
new RangeError('invalid resolution');
- if(Math.abs(w/h-W/H)>.005)throw new RangeError('16:9 resolution required');
- canvas.width=w;canvas.height=h;staticKey='';renderAt(state.time);
-}
-function setFocus(i){if(!Number.isInteger(i)||i< -1||i>5)throw new
RangeError('invalid
focus');state.focus=i;staticKey='';document.getElementById('back').disabled=i<0;renderAt(state.time);}
-function
tick(now){if(state.playing&&state.last){state.time+=(now-state.last)/1000*state.speed;}state.last=now;renderAt(state.time);state.raf=requestAnimationFrame(tick);}
-function
playPause(){state.playing=!state.playing;document.getElementById('play').textContent=state.playing?UI.pause:UI.play;}
-function updateResolution(){if(IS_EXPORT)return;const
r=canvas.getBoundingClientRect();const
width=Math.max(1280,Math.min(3840,Math.round(r.width*devicePixelRatio/16)*16));setResolution(width,width*9/16);}
-function getModels(t){const q=phase(t);return
{cdc:cdcModel(q),transform:transformModel(q),schema:schemaModel(q),checkpoint:checkpointModel(q),splits:splitRecords};}
-window.SeaTunnelMotion={renderAt,setResolution,setFocus,getModels,state,telemetry,duration:DURATION};
-window.addEventListener('error',e=>telemetry.errors.push(e.message));
-document.getElementById('play').onclick=playPause;
-document.getElementById('restart').onclick=()=>renderAt(0);
-document.getElementById('back').onclick=()=>setFocus(-1);
-document.getElementById('speed').onchange=e=>{state.speed=Number(e.target.value);};
-document.getElementById('timeline').oninput=e=>{renderAt(Number(e.target.value));};
-document.getElementById('fullscreen').onclick=async()=>{try{if(document.fullscreenElement)await
document.exitFullscreen();else await
document.getElementById('player').requestFullscreen();}catch(e){document.getElementById('hint').textContent=UI.fullscreenUnavailable;}};
-canvas.onclick=e=>{if(state.focus>=0){setFocus(-1);return;}const
r=canvas.getBoundingClientRect(),x=(e.clientX-r.left)*W/r.width,y=(e.clientY-r.top)*H/r.height;const
i=CARD_POS.findIndex(p=>x>=p[0]&&x<=p[0]+PW&&y>=p[1]&&y<=p[1]+PH);if(i>=0)setFocus(i);};
-window.addEventListener('keydown',e=>{if(['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName))return;if(e.code==='Space'){e.preventDefault();playPause();}if(e.code==='Escape')setFocus(-1);if(e.key==='r'||e.key==='R')renderAt(0);if(/^[1-6]$/.test(e.key))setFocus(Number(e.key)-1);});
-window.addEventListener('resize',updateResolution);
-Promise.all([logo.decode(),document.fonts.ready]).then(()=>{staticKey='';setResolution(1920,1080);updateResolution();telemetry.ready=true;document.getElementById('play').textContent=state.playing?UI.pause:UI.play;renderAt(0);if(!IS_EXPORT)state.raf=requestAnimationFrame(tick);}).catch(e=>{telemetry.errors.push(e.message);document.getElementById('hint').textContent=UI.assetLoadFailed+e.message;});
-</script></body></html>