177 lines
6.5 KiB
TypeScript
177 lines
6.5 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import type { Destination } from "@/lib/types";
|
||
|
||
const TZ_OFFSETS: Record<string, number> = {
|
||
bali: 8,
|
||
lisbon: 0,
|
||
chiangmai: 7,
|
||
mexico: -6,
|
||
barcelona: 1,
|
||
tokyo: 9,
|
||
};
|
||
|
||
const HOME_TZ = 8; // China UTC+8
|
||
const WORK_START = 9;
|
||
const WORK_END = 18;
|
||
|
||
interface Props {
|
||
destinations: Destination[];
|
||
}
|
||
|
||
function getLocalHour(utcOffset: number): number {
|
||
const now = new Date();
|
||
const utc = now.getUTCHours() + now.getUTCMinutes() / 60;
|
||
return Math.floor((utc + utcOffset + 24) % 24);
|
||
}
|
||
|
||
function formatTime(hour: number, min: number): string {
|
||
return `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
|
||
}
|
||
|
||
function getOverlapHours(destOffset: number, homeStart: number, homeEnd: number): number {
|
||
let overlap = 0;
|
||
for (let h = homeStart; h < homeEnd; h++) {
|
||
const destHour = (h - HOME_TZ + destOffset + 24) % 24;
|
||
if (destHour >= 8 && destHour < 20) overlap++;
|
||
}
|
||
return overlap;
|
||
}
|
||
|
||
function overlapLabel(hours: number): { text: string; class: string } {
|
||
if (hours >= 7) return { text: "极佳", class: "excellent" };
|
||
if (hours >= 5) return { text: "良好", class: "good" };
|
||
if (hours >= 3) return { text: "一般", class: "fair" };
|
||
return { text: "困难", class: "poor" };
|
||
}
|
||
|
||
export default function TimezoneBoard({ destinations }: Props) {
|
||
const [now, setNow] = useState(new Date());
|
||
const [homeStart, setHomeStart] = useState(WORK_START);
|
||
const [homeEnd, setHomeEnd] = useState(WORK_END);
|
||
|
||
useEffect(() => {
|
||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
|
||
const cities = useMemo(() =>
|
||
destinations
|
||
.filter((d) => TZ_OFFSETS[d.slug] !== undefined)
|
||
.map((d) => {
|
||
const offset = TZ_OFFSETS[d.slug];
|
||
const localHour = getLocalHour(offset);
|
||
const overlap = getOverlapHours(offset, homeStart, homeEnd);
|
||
const label = overlapLabel(overlap);
|
||
const isWorkTime = localHour >= 9 && localHour < 18;
|
||
return { ...d, offset, localHour, overlap, label, isWorkTime };
|
||
})
|
||
.sort((a, b) => b.overlap - a.overlap),
|
||
[destinations, homeStart, homeEnd]);
|
||
|
||
const homeTime = formatTime(now.getHours(), now.getMinutes());
|
||
const homeSeconds = now.getSeconds();
|
||
|
||
return (
|
||
<section className="section timezone-section" id="timezone">
|
||
<div className="container">
|
||
<div className="section-header reveal">
|
||
<span className="section-tag">🕐 TIMEZONE</span>
|
||
<h2>时区工作看板</h2>
|
||
<p>实时查看各城市当地时间,评估与国内团队的工作时间重叠</p>
|
||
</div>
|
||
|
||
<div className="tz-home-clock reveal">
|
||
<div className="tz-home-time">
|
||
<span className="tz-live-dot" />
|
||
<span className="tz-home-label">🇨🇳 北京时间</span>
|
||
<span className="tz-home-digits">
|
||
{homeTime}<span className="tz-seconds">:{String(homeSeconds).padStart(2, "0")}</span>
|
||
</span>
|
||
</div>
|
||
<div className="tz-work-slider">
|
||
<label>⏰ 你的工作时间: {homeStart}:00 – {homeEnd}:00</label>
|
||
<div className="tz-slider-row">
|
||
<span>开始</span>
|
||
<input type="range" min={6} max={12} value={homeStart}
|
||
onChange={(e) => setHomeStart(Math.min(+e.target.value, homeEnd - 1))} />
|
||
<span>{homeStart}:00</span>
|
||
</div>
|
||
<div className="tz-slider-row">
|
||
<span>结束</span>
|
||
<input type="range" min={13} max={22} value={homeEnd}
|
||
onChange={(e) => setHomeEnd(Math.max(+e.target.value, homeStart + 1))} />
|
||
<span>{homeEnd}:00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="tz-grid reveal">
|
||
{cities.map((city) => {
|
||
const localMin = now.getUTCMinutes();
|
||
const localTime = formatTime(city.localHour, localMin);
|
||
const angle = ((city.localHour % 12) + localMin / 60) * 30;
|
||
return (
|
||
<div key={city.slug} className={`tz-card${city.isWorkTime ? " working" : ""}`}>
|
||
<div className="tz-card-header">
|
||
<span className="tz-card-emoji">{city.emoji}</span>
|
||
<div>
|
||
<strong>{city.name}</strong>
|
||
<span>UTC{city.offset >= 0 ? "+" : ""}{city.offset}</span>
|
||
</div>
|
||
<span className={`tz-overlap-badge ${city.label.class}`}>
|
||
{city.label.text}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="tz-analog">
|
||
<svg viewBox="0 0 100 100">
|
||
<circle cx="50" cy="50" r="44" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="2" />
|
||
{[0, 3, 6, 9].map((n) => (
|
||
<line key={n}
|
||
x1="50" y1="10" x2="50" y2="16"
|
||
stroke="rgba(255,255,255,0.15)" strokeWidth="1.5"
|
||
transform={`rotate(${n * 30} 50 50)`}
|
||
/>
|
||
))}
|
||
<line x1="50" y1="50" x2="50" y2="22"
|
||
stroke="url(#tzHand)" strokeWidth="2.5" strokeLinecap="round"
|
||
transform={`rotate(${angle} 50 50)`}
|
||
/>
|
||
<circle cx="50" cy="50" r="3" fill="#4ECDC4" />
|
||
</svg>
|
||
<span className="tz-digital">{localTime}</span>
|
||
</div>
|
||
|
||
<div className="tz-overlap-bar">
|
||
<div className="tz-overlap-track">
|
||
<div
|
||
className={`tz-overlap-fill ${city.label.class}`}
|
||
style={{ width: `${(city.overlap / Math.max(homeEnd - homeStart, 1)) * 100}%` }}
|
||
/>
|
||
</div>
|
||
<span>重叠 {city.overlap}h / {homeEnd - homeStart}h</span>
|
||
</div>
|
||
|
||
<div className="tz-status">
|
||
{city.isWorkTime ? "💼 当地工作时间" : "🌙 当地非工作时间"}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<svg width="0" height="0">
|
||
<defs>
|
||
<linearGradient id="tzHand" x1="0%" y1="0%" x2="0%" y2="100%">
|
||
<stop offset="0%" stopColor="#FF6B6B" />
|
||
<stop offset="100%" stopColor="#4ECDC4" />
|
||
</linearGradient>
|
||
</defs>
|
||
</svg>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|