Optimize first load: defer mid-page kits, idle chrome, lighter canvas, self-hosted Outfit.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9a6474a69a
commit
f30a7cf879
@ -2,6 +2,8 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
poweredByHeader: false,
|
||||||
|
compress: true,
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -8,6 +8,16 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOGS = [
|
const LOGS = [
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "性能优化",
|
||||||
|
items: [
|
||||||
|
"首页行程/计算器等中屏板块改为视口内按需加载;匹配弹窗与对比弹窗按需拆包",
|
||||||
|
"闲时再挂载引导/反馈等 chrome;去掉全局 MutationObserver 开销",
|
||||||
|
"粒子画布延后启动、去掉连线计算;Loader 更快退出",
|
||||||
|
"Outfit 自托管 + 系统中文字体,去掉阻塞的 Google Fonts 外链",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: "2026-08-29",
|
date: "2026-08-29",
|
||||||
tag: "计划深化",
|
tag: "计划深化",
|
||||||
|
|||||||
@ -20,8 +20,8 @@
|
|||||||
--radius-md: 16px;
|
--radius-md: 16px;
|
||||||
--radius-lg: 24px;
|
--radius-lg: 24px;
|
||||||
--radius-xl: 32px;
|
--radius-xl: 32px;
|
||||||
--font-display: 'Outfit', 'Noto Sans SC', sans-serif;
|
--font-display: var(--font-outfit), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||||
--font-body: 'Noto Sans SC', 'Outfit', sans-serif;
|
--font-body: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", var(--font-outfit), sans-serif;
|
||||||
--nav-height: 72px;
|
--nav-height: 72px;
|
||||||
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,19 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Outfit } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthProvider } from "@/lib/auth";
|
import { AuthProvider } from "@/lib/auth";
|
||||||
import { ToastProvider } from "@/lib/toast";
|
import { ToastProvider } from "@/lib/toast";
|
||||||
|
|
||||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||||
|
|
||||||
|
/** Display font self-hosted; CJK uses system stack to avoid multi‑MB webfont. */
|
||||||
|
const outfit = Outfit({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
variable: "--font-outfit",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(SITE),
|
metadataBase: new URL(SITE),
|
||||||
title: "nomadro · 数字游民旅居指南",
|
title: "nomadro · 数字游民旅居指南",
|
||||||
@ -28,12 +37,7 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" className={outfit.variable}>
|
||||||
<head>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
|||||||
@ -267,12 +267,3 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatcherTrigger({ onClick }: { onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button className="matcher-fab" onClick={onClick} aria-label="智能匹配">
|
|
||||||
<span className="matcher-fab-icon">🎯</span>
|
|
||||||
<span className="matcher-fab-text">智能匹配</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -14,26 +14,20 @@ import WorldMap from "./WorldMap";
|
|||||||
import Destinations from "./Destinations";
|
import Destinations from "./Destinations";
|
||||||
import GlobalSearch from "./GlobalSearch";
|
import GlobalSearch from "./GlobalSearch";
|
||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import CompareModal from "./CompareModal";
|
|
||||||
import DestinationMatcher, { MatcherTrigger } from "./DestinationMatcher";
|
|
||||||
import ScrollProgress from "./ScrollProgress";
|
import ScrollProgress from "./ScrollProgress";
|
||||||
import SectionNav from "./SectionNav";
|
import SectionNav from "./SectionNav";
|
||||||
import CookieConsent from "./CookieConsent";
|
|
||||||
import NomadTips from "./NomadTips";
|
|
||||||
import DailyQuote from "./DailyQuote";
|
|
||||||
import ToolsStrip from "./ToolsStrip";
|
|
||||||
import FeedbackWidget from "./FeedbackWidget";
|
|
||||||
import OnboardingTour from "./OnboardingTour";
|
|
||||||
import KeyboardShortcuts from "./KeyboardShortcuts";
|
|
||||||
import BackToTop from "./BackToTop";
|
import BackToTop from "./BackToTop";
|
||||||
import TripPlanner from "./TripPlanner";
|
import MatcherTrigger from "./MatcherTrigger";
|
||||||
import Calculator from "./Calculator";
|
|
||||||
import SpinGlobe from "./SpinGlobe";
|
|
||||||
import WhenVisible from "./WhenVisible";
|
import WhenVisible from "./WhenVisible";
|
||||||
|
|
||||||
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
|
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
|
||||||
dynamic(loader, { ssr: false, loading: () => null });
|
dynamic(loader, { ssr: false, loading: () => null });
|
||||||
|
|
||||||
|
const SpinGlobe = lazy(() => import("./SpinGlobe"));
|
||||||
|
const DailyQuote = lazy(() => import("./DailyQuote"));
|
||||||
|
const ToolsStrip = lazy(() => import("./ToolsStrip"));
|
||||||
|
const TripPlanner = lazy(() => import("./TripPlanner"));
|
||||||
|
const Calculator = lazy(() => import("./Calculator"));
|
||||||
const TimezoneBoard = lazy(() => import("./TimezoneBoard"));
|
const TimezoneBoard = lazy(() => import("./TimezoneBoard"));
|
||||||
const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
|
const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
|
||||||
const CostCompare = lazy(() => import("./CostCompare"));
|
const CostCompare = lazy(() => import("./CostCompare"));
|
||||||
@ -89,6 +83,13 @@ const Tools = lazy(() => import("./Tools"));
|
|||||||
const BlogSection = lazy(() => import("./BlogSection"));
|
const BlogSection = lazy(() => import("./BlogSection"));
|
||||||
const FAQSection = lazy(() => import("./FAQSection"));
|
const FAQSection = lazy(() => import("./FAQSection"));
|
||||||
const Community = lazy(() => import("./Community"));
|
const Community = lazy(() => import("./Community"));
|
||||||
|
const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
|
||||||
|
const CompareModal = lazy(() => import("./CompareModal"));
|
||||||
|
const CookieConsent = lazy(() => import("./CookieConsent"));
|
||||||
|
const NomadTips = lazy(() => import("./NomadTips"));
|
||||||
|
const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
|
||||||
|
const OnboardingTour = lazy(() => import("./OnboardingTour"));
|
||||||
|
const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts"));
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
@ -107,6 +108,7 @@ export default function HomeClient(props: Props) {
|
|||||||
const [compareResults, setCompareResults] = useState<Destination[] | null>(null);
|
const [compareResults, setCompareResults] = useState<Destination[] | null>(null);
|
||||||
const [compareOpen, setCompareOpen] = useState(false);
|
const [compareOpen, setCompareOpen] = useState(false);
|
||||||
const [matcherOpen, setMatcherOpen] = useState(false);
|
const [matcherOpen, setMatcherOpen] = useState(false);
|
||||||
|
const [chromeReady, setChromeReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMatcher = () => setMatcherOpen(true);
|
const onMatcher = () => setMatcherOpen(true);
|
||||||
@ -115,6 +117,21 @@ export default function HomeClient(props: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const start = () => setChromeReady(true);
|
||||||
|
const ric = window.requestIdleCallback
|
||||||
|
? window.requestIdleCallback(start, { timeout: 2800 })
|
||||||
|
: 0;
|
||||||
|
const t = window.setTimeout(start, 1200);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Above-fold reveal only — deferred sections self-reveal via WhenVisible
|
||||||
|
const nodes = document.querySelectorAll(".reveal:not(.visible)");
|
||||||
|
if (!nodes.length) return;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => entries.forEach((e) => {
|
(entries) => entries.forEach((e) => {
|
||||||
if (e.isIntersecting) {
|
if (e.isIntersecting) {
|
||||||
@ -122,33 +139,13 @@ export default function HomeClient(props: Props) {
|
|||||||
observer.unobserve(e.target);
|
observer.unobserve(e.target);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" }
|
{ threshold: 0.1, rootMargin: "0px 0px -40px 0px" }
|
||||||
);
|
);
|
||||||
|
nodes.forEach((el, i) => {
|
||||||
let scheduled = 0;
|
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.08}s`;
|
||||||
const observeAll = () => {
|
observer.observe(el);
|
||||||
document.querySelectorAll(".reveal:not(.visible)").forEach((el, i) => {
|
});
|
||||||
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.1}s`;
|
return () => observer.disconnect();
|
||||||
observer.observe(el);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const schedule = () => {
|
|
||||||
if (scheduled) return;
|
|
||||||
scheduled = window.setTimeout(() => {
|
|
||||||
scheduled = 0;
|
|
||||||
observeAll();
|
|
||||||
}, 80);
|
|
||||||
};
|
|
||||||
|
|
||||||
observeAll();
|
|
||||||
const mo = new MutationObserver(schedule);
|
|
||||||
mo.observe(document.body, { childList: true, subtree: true });
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
mo.disconnect();
|
|
||||||
if (scheduled) clearTimeout(scheduled);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleCompare = (slug: string) => {
|
const toggleCompare = (slug: string) => {
|
||||||
@ -180,13 +177,13 @@ export default function HomeClient(props: Props) {
|
|||||||
<Hero stats={props.stats} />
|
<Hero stats={props.stats} />
|
||||||
<WorldMap destinations={props.destinations} />
|
<WorldMap destinations={props.destinations} />
|
||||||
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
|
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
|
||||||
<SpinGlobe destinations={props.destinations} />
|
<WhenVisible minHeight={280}><SpinGlobe destinations={props.destinations} /></WhenVisible>
|
||||||
<DailyQuote />
|
<WhenVisible minHeight={120}><DailyQuote /></WhenVisible>
|
||||||
<ToolsStrip />
|
<WhenVisible minHeight={140}><ToolsStrip /></WhenVisible>
|
||||||
<WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><TimezoneBoard destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><JetLagEstimator destinations={props.destinations} /></WhenVisible>
|
||||||
<TripPlanner destinations={props.destinations} />
|
<WhenVisible minHeight={420}><TripPlanner destinations={props.destinations} /></WhenVisible>
|
||||||
<Calculator destinations={props.destinations} />
|
<WhenVisible minHeight={360}><Calculator destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><CostCompare destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><SavingsGoal destinations={props.destinations} /></WhenVisible>
|
||||||
<WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible>
|
<WhenVisible><RunwayCalculator destinations={props.destinations} /></WhenVisible>
|
||||||
@ -244,26 +241,32 @@ export default function HomeClient(props: Props) {
|
|||||||
<BackToTop />
|
<BackToTop />
|
||||||
<SectionNav />
|
<SectionNav />
|
||||||
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
|
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
|
||||||
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
|
{matcherOpen && (
|
||||||
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
|
||||||
<CookieConsent />
|
)}
|
||||||
<NomadTips />
|
{chromeReady && (
|
||||||
<FeedbackWidget />
|
<>
|
||||||
<OnboardingTour />
|
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
||||||
|
<CookieConsent />
|
||||||
|
<NomadTips />
|
||||||
|
<FeedbackWidget />
|
||||||
|
<OnboardingTour />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{compareList.length > 0 && (
|
{compareList.length > 0 && (
|
||||||
<div className="compare-bar">
|
<div className="compare-bar">
|
||||||
<span>⚖️ 已选 {compareList.length}/4</span>
|
<span>⚖️ 已选 {compareList.length}/4</span>
|
||||||
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>快速对比</button>
|
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>快速对比</button>
|
||||||
<Link
|
<Link
|
||||||
href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
|
href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
|
||||||
className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
|
className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
|
||||||
aria-disabled={compareList.length < 2}
|
aria-disabled={compareList.length < 2}
|
||||||
onClick={(e) => { if (compareList.length < 2) e.preventDefault(); }}
|
onClick={(e) => { if (compareList.length < 2) e.preventDefault(); }}
|
||||||
>
|
>
|
||||||
完整对比页
|
完整对比页
|
||||||
</Link>
|
</Link>
|
||||||
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
|
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Brief brand splash — exits ASAP so LCP isn't blocked. */
|
||||||
export default function Loader() {
|
export default function Loader() {
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
const [gone, setGone] = useState(false);
|
const [gone, setGone] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hide = () => setDone(true);
|
|
||||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
const delay = reduced ? 0 : 380;
|
const delay = reduced ? 0 : document.readyState === "complete" ? 40 : 120;
|
||||||
|
|
||||||
|
const hide = () => setDone(true);
|
||||||
|
|
||||||
if (document.readyState === "complete") {
|
if (document.readyState === "complete") {
|
||||||
const t = setTimeout(hide, delay);
|
const t = setTimeout(hide, delay);
|
||||||
@ -18,7 +20,7 @@ export default function Loader() {
|
|||||||
|
|
||||||
const onLoad = () => setTimeout(hide, delay);
|
const onLoad = () => setTimeout(hide, delay);
|
||||||
window.addEventListener("load", onLoad);
|
window.addEventListener("load", onLoad);
|
||||||
const fallback = setTimeout(hide, reduced ? 200 : 1200);
|
const fallback = setTimeout(hide, reduced ? 120 : 600);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("load", onLoad);
|
window.removeEventListener("load", onLoad);
|
||||||
clearTimeout(fallback);
|
clearTimeout(fallback);
|
||||||
@ -27,7 +29,7 @@ export default function Loader() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!done) return;
|
if (!done) return;
|
||||||
const t = setTimeout(() => setGone(true), 500);
|
const t = setTimeout(() => setGone(true), 280);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [done]);
|
}, [done]);
|
||||||
|
|
||||||
|
|||||||
11
frontend/src/components/MatcherTrigger.tsx
Normal file
11
frontend/src/components/MatcherTrigger.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** Lightweight FAB — kept separate so DestinationMatcher can be code-split. */
|
||||||
|
export default function MatcherTrigger({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className="matcher-fab" onClick={onClick} aria-label="智能匹配">
|
||||||
|
<span className="matcher-fab-icon">🎯</span>
|
||||||
|
<span className="matcher-fab-text">智能匹配</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/** Ambient particle field — deferred start, no O(n²) links, pauses when hidden. */
|
||||||
export default function ParticleCanvas() {
|
export default function ParticleCanvas() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d", { alpha: true });
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
@ -18,85 +19,95 @@ export default function ParticleCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let animId = 0;
|
let animId = 0;
|
||||||
let running = true;
|
let running = false;
|
||||||
|
let started = false;
|
||||||
|
let resizeTimer = 0;
|
||||||
let particles: {
|
let particles: {
|
||||||
x: number; y: number; size: number;
|
x: number; y: number; size: number;
|
||||||
speedX: number; speedY: number; opacity: number; hue: number;
|
speedX: number; speedY: number; opacity: number; hue: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
canvas.width = window.innerWidth;
|
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||||
canvas.height = window.innerHeight;
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
canvas.width = Math.floor(w * dpr);
|
||||||
|
canvas.height = Math.floor(h * dpr);
|
||||||
|
canvas.style.width = `${w}px`;
|
||||||
|
canvas.style.height = `${h}px`;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const create = () => {
|
const create = () => {
|
||||||
const count = Math.min(Math.floor(window.innerWidth / 28), 48);
|
const w = window.innerWidth;
|
||||||
|
const count = Math.min(Math.floor(w / 40), 28);
|
||||||
particles = Array.from({ length: count }, () => ({
|
particles = Array.from({ length: count }, () => ({
|
||||||
x: Math.random() * canvas.width,
|
x: Math.random() * w,
|
||||||
y: Math.random() * canvas.height,
|
y: Math.random() * window.innerHeight,
|
||||||
size: Math.random() * 2 + 0.5,
|
size: Math.random() * 2 + 0.5,
|
||||||
speedX: (Math.random() - 0.5) * 0.25,
|
speedX: (Math.random() - 0.5) * 0.22,
|
||||||
speedY: (Math.random() - 0.5) * 0.25,
|
speedY: (Math.random() - 0.5) * 0.22,
|
||||||
opacity: Math.random() * 0.4 + 0.1,
|
opacity: Math.random() * 0.35 + 0.08,
|
||||||
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
|
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResize = () => {
|
|
||||||
resize();
|
|
||||||
create();
|
|
||||||
};
|
|
||||||
|
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
const w = window.innerWidth;
|
||||||
const linkDist = window.innerWidth < 768 ? 0 : 90;
|
const h = window.innerHeight;
|
||||||
particles.forEach((p, i) => {
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
for (const p of particles) {
|
||||||
p.x += p.speedX;
|
p.x += p.speedX;
|
||||||
p.y += p.speedY;
|
p.y += p.speedY;
|
||||||
if (p.x < 0) p.x = canvas.width;
|
if (p.x < 0) p.x = w;
|
||||||
if (p.x > canvas.width) p.x = 0;
|
if (p.x > w) p.x = 0;
|
||||||
if (p.y < 0) p.y = canvas.height;
|
if (p.y < 0) p.y = h;
|
||||||
if (p.y > canvas.height) p.y = 0;
|
if (p.y > h) p.y = 0;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
|
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
}
|
||||||
if (linkDist > 0) {
|
|
||||||
for (let j = i + 1; j < particles.length; j++) {
|
|
||||||
const p2 = particles[j];
|
|
||||||
const dx = p.x - p2.x;
|
|
||||||
const dy = p.y - p2.y;
|
|
||||||
const dist = Math.hypot(dx, dy);
|
|
||||||
if (dist < linkDist) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(p.x, p.y);
|
|
||||||
ctx.lineTo(p2.x, p2.y);
|
|
||||||
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.06 * (1 - dist / linkDist)})`;
|
|
||||||
ctx.lineWidth = 0.5;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
animId = requestAnimationFrame(draw);
|
animId = requestAnimationFrame(draw);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onVis = () => {
|
const start = () => {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
resize();
|
||||||
|
create();
|
||||||
running = document.visibilityState === "visible";
|
running = document.visibilityState === "visible";
|
||||||
if (running) draw();
|
if (running) draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = window.setTimeout(() => {
|
||||||
|
resize();
|
||||||
|
create();
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVis = () => {
|
||||||
|
running = document.visibilityState === "visible" && started;
|
||||||
|
if (running) draw();
|
||||||
else cancelAnimationFrame(animId);
|
else cancelAnimationFrame(animId);
|
||||||
};
|
};
|
||||||
|
|
||||||
resize();
|
const ric = window.requestIdleCallback
|
||||||
create();
|
? window.requestIdleCallback(() => start(), { timeout: 1800 })
|
||||||
draw();
|
: 0;
|
||||||
window.addEventListener("resize", onResize);
|
const fallback = window.setTimeout(start, 400);
|
||||||
|
|
||||||
|
window.addEventListener("resize", onResize, { passive: true });
|
||||||
document.addEventListener("visibilitychange", onVis);
|
document.addEventListener("visibilitychange", onVis);
|
||||||
return () => {
|
return () => {
|
||||||
|
running = false;
|
||||||
cancelAnimationFrame(animId);
|
cancelAnimationFrame(animId);
|
||||||
|
clearTimeout(fallback);
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
|
||||||
window.removeEventListener("resize", onResize);
|
window.removeEventListener("resize", onResize);
|
||||||
document.removeEventListener("visibilitychange", onVis);
|
document.removeEventListener("visibilitychange", onVis);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -35,6 +35,14 @@ export default function WhenVisible({
|
|||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [rootMargin, show]);
|
}, [rootMargin, show]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show || !ref.current) return;
|
||||||
|
// Reveal animations for deferred sections without a global MutationObserver
|
||||||
|
ref.current.querySelectorAll(".reveal:not(.visible)").forEach((node) => {
|
||||||
|
node.classList.add("visible");
|
||||||
|
});
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={show ? undefined : { minHeight }} data-deferred={show ? "ready" : "pending"}>
|
<div ref={ref} style={show ? undefined : { minHeight }} data-deferred={show ? "ready" : "pending"}>
|
||||||
{show ? children : <div className="deferred-skeleton" aria-hidden />}
|
{show ? children : <div className="deferred-skeleton" aria-hidden />}
|
||||||
|
|||||||
@ -35,6 +35,14 @@ FILES = [
|
|||||||
"frontend/src/components/TripPlanner.tsx",
|
"frontend/src/components/TripPlanner.tsx",
|
||||||
"frontend/src/components/WorldMap.tsx",
|
"frontend/src/components/WorldMap.tsx",
|
||||||
"frontend/src/components/MovePlanClient.tsx",
|
"frontend/src/components/MovePlanClient.tsx",
|
||||||
|
"frontend/src/components/HomeClient.tsx",
|
||||||
|
"frontend/src/components/MatcherTrigger.tsx",
|
||||||
|
"frontend/src/components/DestinationMatcher.tsx",
|
||||||
|
"frontend/src/components/ParticleCanvas.tsx",
|
||||||
|
"frontend/src/components/Loader.tsx",
|
||||||
|
"frontend/src/components/WhenVisible.tsx",
|
||||||
|
"frontend/src/app/layout.tsx",
|
||||||
|
"frontend/next.config.ts",
|
||||||
"frontend/src/lib/types.ts",
|
"frontend/src/lib/types.ts",
|
||||||
"frontend/src/lib/planMeta.ts",
|
"frontend/src/lib/planMeta.ts",
|
||||||
"frontend/src/lib/compareScore.ts",
|
"frontend/src/lib/compareScore.ts",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user