(() => {
const canvas = document.getElementById('joinProgressCanvas');
if (!canvas) {
return;
}
const context = canvas.getContext('2d');
if (!context) {
return;
}
const animeLib = window.anime;
const hasAnime = typeof animeLib === 'function';
const waveTrack = document.getElementById('joinWaveTrack');
const waveFill = document.getElementById('joinWaveFill');
const waveBoat = document.getElementById('joinWaveBoat');
const waveMarks = document.getElementById('joinWaveMarks');
const waveProgress = waveTrack ? waveTrack.closest('.join-wave-progress') : null;
const overlayPanel = document.querySelector('.join-canvas-overlay');
const joinData = window.itstudioJoinData && typeof window.itstudioJoinData === 'object'
? window.itstudioJoinData
: {};
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let width = 0;
let height = 0;
let dpr = 1;
let rafId = 0;
let targetProgress = 0;
let displayedProgress = 0;
let trackLeft = 0;
let trackWidth = 0;
let stageMarkers = [];
let stageLayout = null;
const waveShape1 = { base: 0.16, amp: 7.5, len: 280, speed: 0.9 };
const waveShape2 = { base: 0.44, amp: 6, len: 340, speed: 0.75 };
const waveShape3 = { base: 0.68, amp: 6.5, len: 400, speed: 0.62 };
const DAY_MS = 24 * 60 * 60 * 1000;
const ONE_DAY_STAGE_WEIGHT_MS = DAY_MS * 1.8;
const palettes = {
light: {
wave1: 'rgba(122, 178, 230, 0.95)',
wave2: 'rgba(73, 134, 197, 0.72)',
wave3: 'rgba(40, 96, 161, 0.78)',
},
dark: {
wave1: 'rgba(86, 146, 214, 0.92)',
wave2: 'rgba(49, 104, 172, 0.78)',
wave3: 'rgba(27, 73, 136, 0.82)',
},
};
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function getNumber(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function getStages() {
return Array.isArray(joinData.stages) ? joinData.stages : [];
}
function getStageDurationMs(stage) {
const start = getNumber(stage ? stage.startTs : null);
const end = getNumber(stage ? stage.endTs : null);
if (start === null || end === null) {
return null;
}
return Math.max(0, end - start);
}
function getEffectiveStageWeightMs(stage) {
const duration = getStageDurationMs(stage);
if (duration === null) {
return ONE_DAY_STAGE_WEIGHT_MS;
}
if (duration <= DAY_MS) {
return ONE_DAY_STAGE_WEIGHT_MS;
}
return duration;
}
function buildStageLayout(stages) {
if (!stages.length) {
return {
markerByIndex: [],
endByIndex: [],
};
}
const weights = stages.map((stage) => getEffectiveStageWeightMs(stage));
const totalWeight = Math.max(1, weights.reduce((sum, w) => sum + w, 0));
const markerByIndex = [];
const endByIndex = [];
let cumulative = 0;
for (let i = 0; i < stages.length; i += 1) {
markerByIndex[i] = clamp(cumulative / totalWeight, 0, 1);
cumulative += weights[i];
endByIndex[i] = clamp(cumulative / totalWeight, 0, 1);
}
endByIndex[endByIndex.length - 1] = 1;
return { markerByIndex, endByIndex };
}
function getStageLayout(stages) {
if (!stageLayout || !stageLayout.markerByIndex || stageLayout.markerByIndex.length !== stages.length) {
stageLayout = buildStageLayout(stages);
}
return stageLayout;
}
function getNavigationType() {
if (window.performance && typeof window.performance.getEntriesByType === 'function') {
const entries = window.performance.getEntriesByType('navigation');
if (entries && entries.length > 0 && entries[0].type) {
return entries[0].type;
}
}
if (window.performance && window.performance.navigation) {
if (window.performance.navigation.type === 1) {
return 'reload';
}
if (window.performance.navigation.type === 2) {
return 'back_forward';
}
}
return 'navigate';
}
function shouldAnimateEntry() {
if (prefersReducedMotion.matches) {
return false;
}
return getNavigationType() !== 'reload';
}
function setupOverlayEntryAnimation() {
if (!overlayPanel) {
return;
}
overlayPanel.classList.remove('is-enter-animate');
if (!shouldAnimateEntry()) {
return;
}
requestAnimationFrame(() => {
overlayPanel.classList.add('is-enter-animate');
});
}
function resize() {
dpr = window.devicePixelRatio || 1;
width = canvas.clientWidth;
height = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
updateTrackMetrics();
}
function updateTrackMetrics() {
if (!waveTrack) {
trackLeft = 0;
trackWidth = width;
return;
}
const canvasRect = canvas.getBoundingClientRect();
const trackRect = waveTrack.getBoundingClientRect();
trackLeft = clamp(trackRect.left - canvasRect.left, 0, width);
trackWidth = Number.isFinite(trackRect.width) && trackRect.width > 0 ? trackRect.width : width;
}
function drawWave(color, yBase, amplitude, wavelength, speed, time) {
const overscan = 24;
const step = 6;
context.beginPath();
context.moveTo(-overscan, height + 2);
for (let x = -overscan; x <= width + overscan; x += step) {
const theta = (x / wavelength) * Math.PI * 2 + time * speed;
const y = yBase + Math.sin(theta) * amplitude;
context.lineTo(x, y);
}
context.lineTo(width + overscan, height + 2);
context.lineTo(-overscan, height + 2);
context.closePath();
context.fillStyle = color;
context.fill();
}
function sampleWaveAtX(shape, x, time) {
const theta = (x / shape.len) * Math.PI * 2 + time * shape.speed;
const y = Math.sin(theta) * shape.amp;
const slope = Math.cos(theta) * (Math.PI * 2 / shape.len) * shape.amp;
return { y, slope };
}
function getWaveXFromProgress(progress) {
const safeProgress = clamp(progress, 0, 1);
const currentTrackWidth = trackWidth > 0 ? trackWidth : width;
return trackLeft + (safeProgress * currentTrackWidth);
}
function applyFloatingMotion(time) {
if (width <= 0) {
return;
}
const boatWave = sampleWaveAtX(waveShape3, getWaveXFromProgress(displayedProgress), time);
if (waveBoat) {
const boatTilt = clamp(boatWave.slope * 34, -7.2, 7.2);
waveBoat.style.setProperty('--boat-wave-offset', `${boatWave.y.toFixed(2)}px`);
waveBoat.style.setProperty('--boat-tilt', `${boatTilt.toFixed(2)}deg`);
}
stageMarkers.forEach((marker) => {
if (marker.type === 'lighthouse') {
marker.element.style.setProperty('--mark-wave-offset', '0px');
marker.element.style.setProperty('--mark-tilt', '0deg');
return;
}
const markerWave = sampleWaveAtX(waveShape3, getWaveXFromProgress(marker.progress), time);
const markerTilt = clamp(markerWave.slope * 34, -7.2, 7.2);
marker.element.style.setProperty('--mark-wave-offset', `${markerWave.y.toFixed(2)}px`);
marker.element.style.setProperty('--mark-tilt', `${markerTilt.toFixed(2)}deg`);
});
}
function renderWaves(timestamp) {
const theme = getTheme();
const palette = palettes[theme];
const time = timestamp * 0.001;
context.clearRect(0, 0, width, height);
drawWave(palette.wave1, height * waveShape1.base, waveShape1.amp, waveShape1.len, waveShape1.speed, time);
drawWave(palette.wave2, height * waveShape2.base, waveShape2.amp, waveShape2.len, waveShape2.speed, time);
drawWave(palette.wave3, height * waveShape3.base, waveShape3.amp, waveShape3.len, waveShape3.speed, time);
applyFloatingMotion(time);
rafId = requestAnimationFrame(renderWaves);
}
function stopWaves() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
}
function drawStaticWaves() {
const theme = getTheme();
const palette = palettes[theme];
context.clearRect(0, 0, width, height);
drawWave(palette.wave1, height * waveShape1.base, waveShape1.amp, waveShape1.len, waveShape1.speed, 0);
drawWave(palette.wave2, height * waveShape2.base, waveShape2.amp, waveShape2.len, waveShape2.speed, 0);
drawWave(palette.wave3, height * waveShape3.base, waveShape3.amp, waveShape3.len, waveShape3.speed, 0);
applyFloatingMotion(0);
}
function startWaves() {
stopWaves();
resize();
if (prefersReducedMotion.matches) {
drawStaticWaves();
return;
}
rafId = requestAnimationFrame(renderWaves);
}
function computeTargetProgress() {
const stages = getStages();
if (!stages.length) {
return 0;
}
const layout = getStageLayout(stages);
const markerByIndex = layout.markerByIndex;
const endByIndex = layout.endByIndex;
const currentIndex = getNumber(joinData.currentStageIndex);
const nowTs = getNumber(joinData.nowTs) || Date.now();
// 有进行中阶段时,在该阶段对应区间内按时间连续推进。
if (currentIndex !== null && currentIndex >= 0 && currentIndex < stages.length) {
const stage = stages[currentIndex];
const startProgress = getNumber(markerByIndex[currentIndex]) ?? 0;
const endProgress = getNumber(endByIndex[currentIndex]) ?? startProgress;
const startTs = getNumber(stage ? stage.startTs : null);
const endTs = getNumber(stage ? stage.endTs : null);
if (startTs !== null && endTs !== null && endTs > startTs) {
const ratio = clamp((nowTs - startTs) / (endTs - startTs), 0, 1);
return clamp(startProgress + ((endProgress - startProgress) * ratio), 0, 1);
}
return clamp(startProgress, 0, 1);
}
// 无进行中阶段时,停在最近已完成阶段的末端。
let lastCompletedIndex = -1;
for (let i = 0; i < stages.length; i += 1) {
if (stages[i] && stages[i].status === 'completed') {
lastCompletedIndex = i;
}
}
// 阶段空档期:在“上一阶段浮标”和“下一阶段浮标”之间按时间线性推进。
if (lastCompletedIndex >= 0) {
let nextUpcomingIndex = -1;
for (let i = lastCompletedIndex + 1; i < stages.length; i += 1) {
if (stages[i] && stages[i].status === 'upcoming') {
nextUpcomingIndex = i;
break;
}
}
if (nextUpcomingIndex >= 0) {
const completedStage = stages[lastCompletedIndex];
const upcomingStage = stages[nextUpcomingIndex];
const gapStartTs = getNumber(completedStage ? completedStage.endTs : null);
const gapEndTs = getNumber(upcomingStage ? upcomingStage.startTs : null);
const fromProgress = clamp(getNumber(markerByIndex[lastCompletedIndex]) ?? 0, 0, 1);
const toProgress = clamp(getNumber(markerByIndex[nextUpcomingIndex]) ?? fromProgress, 0, 1);
if (gapStartTs !== null && gapEndTs !== null && gapEndTs > gapStartTs && nowTs > gapStartTs && nowTs < gapEndTs) {
const gapRatio = clamp((nowTs - gapStartTs) / (gapEndTs - gapStartTs), 0, 1);
return clamp(fromProgress + ((toProgress - fromProgress) * gapRatio), 0, 1);
}
if (nowTs <= gapStartTs) {
return fromProgress;
}
if (nowTs < gapEndTs) {
return clamp((fromProgress + toProgress) * 0.5, 0, 1);
}
}
}
if (lastCompletedIndex >= 0) {
return clamp(getNumber(endByIndex[lastCompletedIndex]) ?? 0, 0, 1);
}
return 0;
}
function getBuoyMarkerSvg() {
return `
`;
}
function getLighthouseMarkerSvg() {
return `
`;
}
function renderStageMarks() {
if (!waveMarks) {
return;
}
stageMarkers = [];
waveMarks.innerHTML = '';
if (waveProgress) {
waveProgress.querySelectorAll('.join-wave-mark.is-lighthouse.is-docked').forEach((node) => node.remove());
}
const stages = getStages();
if (!stages.length) {
return;
}
const layout = getStageLayout(stages);
const markerByIndex = layout.markerByIndex;
const currentIndex = getNumber(joinData.currentStageIndex);
const safeCurrentIndex = currentIndex === null ? -1 : Math.round(currentIndex);
stages.forEach((stage, index) => {
const marker = document.createElement('span');
marker.className = 'join-wave-mark';
const isLighthouse = index === stages.length - 1 || (stage && stage.key === 'public_notice');
const progress = isLighthouse
? 1
: clamp(getNumber(markerByIndex[index]) ?? 0, 0, 1);
marker.classList.add(isLighthouse ? 'is-lighthouse' : 'is-buoy');
if (index === safeCurrentIndex) {
marker.classList.add('is-active');
}
const icon = document.createElement('span');
icon.className = 'join-wave-mark-icon';
icon.innerHTML = isLighthouse ? getLighthouseMarkerSvg() : getBuoyMarkerSvg();
marker.appendChild(icon);
const beams = isLighthouse ? Array.from(icon.querySelectorAll('.marker-lh-beam')) : [];
if (isLighthouse && waveProgress) {
marker.classList.add('is-docked');
marker.style.right = '';
marker.style.left = 'calc(100% - clamp(18px, 2.2vw, 30px))';
waveProgress.appendChild(marker);
} else {
marker.style.left = `${(progress * 100).toFixed(3)}%`;
waveMarks.appendChild(marker);
}
stageMarkers.push({
element: marker,
icon,
beams,
progress,
type: isLighthouse ? 'lighthouse' : 'buoy',
});
});
}
function stopBoatAnimations() {
if (!hasAnime) {
return;
}
const targets = [waveFill, waveBoat];
stageMarkers.forEach((marker) => {
if (marker.icon) {
targets.push(marker.icon);
}
if (Array.isArray(marker.beams) && marker.beams.length) {
marker.beams.forEach((beam) => targets.push(beam));
}
});
animeLib.remove(targets);
}
function startLighthouseBeacon() {
const allBeams = [];
const leftBeams = [];
const rightBeams = [];
stageMarkers.forEach((marker) => {
if (marker.type !== 'lighthouse' || !Array.isArray(marker.beams)) {
return;
}
marker.beams.forEach((beam) => {
allBeams.push(beam);
beam.style.opacity = '0.28';
if (beam.classList.contains('marker-lh-beam-left')) {
leftBeams.push(beam);
} else if (beam.classList.contains('marker-lh-beam-right')) {
rightBeams.push(beam);
}
});
});
if (!allBeams.length || !hasAnime || prefersReducedMotion.matches) {
return;
}
const duration = 3600;
const easing = 'easeInOutSine';
if (rightBeams.length) {
animeLib({
targets: rightBeams,
opacity: [0.2, 0.86, 0.2],
duration,
easing,
loop: true,
});
}
if (leftBeams.length) {
animeLib({
targets: leftBeams,
opacity: [0.2, 0.82, 0.2],
duration,
delay: duration / 2,
easing,
loop: true,
});
} else if (!rightBeams.length) {
animeLib({
targets: allBeams,
opacity: [0.2, 0.84, 0.2],
duration,
easing,
loop: true,
});
}
}
function setProgress(value) {
const safe = clamp(value, 0, 1);
displayedProgress = safe;
const percent = `${(safe * 100).toFixed(3)}%`;
if (waveFill) {
waveFill.style.width = percent;
}
if (waveBoat) {
waveBoat.style.left = percent;
}
}
function animateBoatToTarget() {
if (!waveBoat || !waveFill) {
return;
}
stopBoatAnimations();
if (!hasAnime || !shouldAnimateEntry()) {
setProgress(targetProgress);
startLighthouseBeacon();
return;
}
const state = { value: 0 };
setProgress(0);
animeLib({
targets: state,
value: targetProgress,
duration: 1750,
easing: 'easeOutCubic',
update: () => {
setProgress(state.value);
},
complete: () => {
setProgress(targetProgress);
startLighthouseBeacon();
},
});
animeLib({
targets: waveFill,
opacity: [0.55, 1],
duration: 1100,
easing: 'easeOutSine',
});
const activeIcon = waveMarks ? waveMarks.querySelector('.join-wave-mark.is-active .join-wave-mark-icon') : null;
if (activeIcon) {
animeLib({
targets: activeIcon,
scale: [1, 1.24, 1],
opacity: [0.82, 1, 0.92],
duration: 850,
delay: 980,
easing: 'easeOutSine',
});
}
}
function handleMotionPreferenceChange() {
targetProgress = computeTargetProgress();
setProgress(targetProgress);
stopBoatAnimations();
startWaves();
startLighthouseBeacon();
}
const observer = new MutationObserver(() => {
if (prefersReducedMotion.matches) {
drawStaticWaves();
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
if (typeof prefersReducedMotion.addEventListener === 'function') {
prefersReducedMotion.addEventListener('change', handleMotionPreferenceChange);
} else if (typeof prefersReducedMotion.addListener === 'function') {
prefersReducedMotion.addListener(handleMotionPreferenceChange);
}
window.addEventListener('resize', () => {
startWaves();
setProgress(targetProgress);
});
window.addEventListener('orientationchange', () => {
startWaves();
setProgress(targetProgress);
});
window.addEventListener('beforeunload', () => {
stopWaves();
stopBoatAnimations();
});
targetProgress = computeTargetProgress();
setupOverlayEntryAnimation();
renderStageMarks();
startWaves();
animateBoatToTarget();
})();