Skip to content

Commit a783222

Browse files
author
UI Upgrade
committed
ui: improve layout, animations and loading performance
1 parent c2b6b75 commit a783222

14 files changed

Lines changed: 378 additions & 223 deletions

index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
<link rel="manifest" href="/manifest.json" />
99
<link rel="canonical" href="https://9dk9jptv8h-hue.github.io/japan-otaku-map/" />
1010

11+
<!-- 地图瓦片与样式提前建立连接,缩短首屏等待 -->
12+
<link rel="preconnect" href="https://tiles.openfreemap.org" crossorigin />
13+
<link rel="dns-prefetch" href="https://tiles.openfreemap.org" />
14+
<link rel="preconnect" href="https://japan-map-ai.9dk9jptv8h-hue.workers.dev" crossorigin />
15+
<link rel="dns-prefetch" href="https://japan-map-ai.9dk9jptv8h-hue.workers.dev" />
16+
1117
<title>日本动漫店铺地图</title>
1218

1319
<!-- SEO -->

src/App.tsx

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const CITY_DOTS = [
3333
]
3434

3535
function WelcomeScreen({ onSkip }: { onSkip: () => void }) {
36-
const [count, setCount] = useState(mockLocations.length)
36+
const [count, setCount] = useState(0)
3737

3838
/* 数字递增动画 — 0 → 176 */
3939
useEffect(() => {
@@ -59,7 +59,7 @@ function WelcomeScreen({ onSkip }: { onSkip: () => void }) {
5959

6060
/* 背景粒子 — 40个彩色光点 */
6161
const particles = useMemo(() =>
62-
Array.from({ length: 40 }, (_, i) => ({
62+
Array.from({ length: window.innerWidth < 768 ? 20 : 40 }, (_, i) => ({
6363
id: i,
6464
left: `${Math.random() * 100}%`,
6565
top: `${Math.random() * 100}%`,
@@ -188,7 +188,7 @@ function WelcomeScreen({ onSkip }: { onSkip: () => void }) {
188188
type="button"
189189
onClick={onSkip}
190190
className="px-5 py-2 rounded-full text-[13px] font-medium text-white/70 hover:text-white border border-white/20 hover:border-white/40 transition-colors"
191-
style={{ animation: 'fadeInUp 0.5s 2s ease-out both' }}
191+
style={{ animation: 'fadeInUp 0.5s 1.2s ease-out both' }}
192192
>
193193
跳过,直接进入地图 →
194194
</button>
@@ -309,7 +309,7 @@ function markWelcomeSeen(): void {
309309
}
310310

311311
export default function App() {
312-
const [mapRender, setMapRender] = useState(false)
312+
const [mapRender] = useState(true)
313313
// 会话内已看过欢迎页 → 跳过欢迎动画,直接进入加载态
314314
const [welcomeSkipped] = useState(hasSeenWelcome)
315315
const [welcomeMounted, setWelcomeMounted] = useState(!welcomeSkipped)
@@ -329,49 +329,41 @@ export default function App() {
329329
const skipWelcome = () => {
330330
markWelcomeSeen()
331331
setWelcomeOpacity(0)
332-
setLoadingOpacity(1)
332+
setLoadingOpacity(useMapStore.getState().isMapReady ? 0 : 1)
333333
}
334334

335335
// 阶段1→2:欢迎→加载(欢迎淡出,加载淡入交叉过渡)
336336
useEffect(() => {
337337
if (welcomeSkipped) return // 已看过:不安排定时器,保持加载态
338338
const t = setTimeout(() => {
339339
setWelcomeOpacity(0) // 欢迎开始淡出
340-
setLoadingOpacity(1) // 加载开始淡入(此时地图还没渲染!)
340+
setLoadingOpacity(useMapStore.getState().isMapReady ? 0 : 1) // 地图已就绪则直接过渡到地图
341341
markWelcomeSeen()
342-
}, isMobile ? 2200 : 2500)
342+
}, isMobile ? 2000 : 2400)
343343
return () => clearTimeout(t)
344344
}, [isMobile, welcomeSkipped])
345345

346-
// 加载页淡入完成后(opacity达到1),才开始渲染地图
347-
useEffect(() => {
348-
if (loadingOpacity < 0.5) return // loading还没淡入完
349-
const t = setTimeout(() => {
350-
setMapRender(true) // 地图开始加载(加载页已完全覆盖)
351-
}, 500) // 等loading淡入动画(0.5s)完成
352-
return () => clearTimeout(t)
353-
}, [loadingOpacity])
354346

355347
// 地图就绪 → 加载页淡出
356348
useEffect(() => {
357-
if (!mapRender) return
349+
// 地图已后台常驻,无需 gating
358350

359351
const safetyTimer = setTimeout(() => {
360352
// Force exit loading after 20s even if map not ready
361353
if (!isMapReady) {
362354
console.warn('[App] Map loading timeout, forcing exit')
363-
setMapRender(true)
355+
// 地图已后台常驻,此处仅记录超时
364356
}
365357
}, 20000)
366358

367359
if (!isMapReady) return () => clearTimeout(safetyTimer)
368360

369-
// 额外等1秒让进度条走完
361+
// 450ms 冲刺让进度条走完
370362
const t = setTimeout(() => {
371363
setLoadingOpacity(0) // 加载页淡出,地图露出来
372-
}, 1000)
364+
}, 450)
373365
return () => { clearTimeout(t); clearTimeout(safetyTimer) }
374-
}, [mapRender, isMapReady])
366+
}, [isMapReady])
375367

376368
// 欢迎层淡出完成后卸载(粒子动画不再占用 CPU/GPU)
377369
useEffect(() => {
@@ -382,14 +374,14 @@ export default function App() {
382374

383375
// 加载层淡出完成后卸载(进度条/呼吸灯动画停止)
384376
useEffect(() => {
385-
if (loadingOpacity > 0 || !mapRender) return
377+
if (loadingOpacity > 0) return
386378
const t = setTimeout(() => setLoadingMounted(false), 600)
387379
return () => clearTimeout(t)
388-
}, [loadingOpacity, mapRender])
380+
}, [loadingOpacity])
389381

390382
return (
391383
<ErrorBoundary>
392-
{/* 地图层 — 最低。加载页就绪后才开始渲染 */}
384+
{/* 地图层 — 应用挂载即开始后台加载,加载页只负责视觉过渡 */}
393385
{mapRender && <AppShell locations={mockLocations} />}
394386

395387
{/* 加载过渡层 — 中间 */}

src/components/layout/DesktopLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function DesktopLayout({ locations }: DesktopLayoutProps) {
2323
<RouteLayer />
2424
</MapView>
2525
{/* 地图控件 */}
26-
<MapControls className="absolute top-4 right-4 z-[1000]" />
26+
<MapControls className="absolute top-5 right-5 z-[1000]" />
2727
</div>
2828

2929
{/* 侧边栏浮在地图上方 */}

src/components/layout/MobileLayout.tsx

Lines changed: 70 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { RegionSelect } from '@/components/sidebar/RegionSelect'
1212
import { CardList } from '@/components/sidebar/CardList'
1313
import { useUIStore } from '@/store/useUIStore'
1414
import { useFilteredLocations } from '@/hooks/useFilteredLocations'
15-
import { Menu, X } from 'lucide-react'
15+
import { ChevronRight, List, Menu, X } from 'lucide-react'
1616
import { cn } from '@/utils/cn'
1717

1818
interface MobileLayoutProps {
@@ -24,15 +24,15 @@ export function MobileLayout({ locations }: MobileLayoutProps) {
2424
const setSidebarOpen = useUIStore(s => s.setSidebarOpen)
2525
const { filteredLocations, regionList } = useFilteredLocations()
2626

27-
// Bug 1: Lock body scroll when drawer is open
27+
// 抽屉打开时锁定背景滚动
2828
useEffect(() => {
2929
if (sidebarOpen) {
3030
document.body.style.overflow = 'hidden'
3131
return () => { document.body.style.overflow = '' }
3232
}
3333
}, [sidebarOpen])
3434

35-
// Bug 4: Close drawer on Escape key
35+
// Esc 关闭抽屉
3636
useEffect(() => {
3737
const handleKeyDown = (e: KeyboardEvent) => {
3838
if (e.key === 'Escape' && sidebarOpen) {
@@ -52,93 +52,122 @@ export function MobileLayout({ locations }: MobileLayoutProps) {
5252
</MapView>
5353

5454
{/* 地图控件 */}
55-
<MapControls className="absolute top-16 right-3 z-[999]" />
56-
57-
{/* 顶部搜索栏 — 侧边栏打开时不渲染 */}
55+
<MapControls
56+
className="absolute right-3 z-[999]"
57+
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 64px)' }}
58+
/>
5859
{!sidebarOpen && (
5960
<div className="absolute top-0 left-0 right-0 z-[1000] p-2 pt-safe pointer-events-none">
6061
<div className="flex items-center gap-2 pointer-events-auto">
6162
<button
63+
type="button"
6264
onClick={() => setSidebarOpen(true)}
63-
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl glass shadow-soft border border-[var(--color-border)] active:scale-95 transition-transform"
65+
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl glass border border-[var(--color-border)] shadow-soft transition-[transform,box-shadow] duration-200 active:scale-95"
6466
aria-label="打开菜单"
6567
>
6668
<Menu className="h-5 w-5 text-[var(--color-text-dim)]" />
6769
</button>
68-
<div className="flex-1 glass rounded-xl shadow-soft border border-[var(--color-border)] pointer-events-auto">
70+
<div className="min-w-0 flex-1 rounded-2xl glass border border-[var(--color-border)] shadow-soft pointer-events-auto">
6971
<SearchBar />
7072
</div>
7173
</div>
7274
</div>
7375
)}
7476

75-
{/* 遮罩 — 点击关闭抽屉 */}
77+
{/* 遮罩 */}
7678
<div
7779
role="dialog"
7880
aria-label="关闭菜单"
7981
className={cn(
80-
'fixed inset-0 z-40 bg-black/40 transition-opacity duration-300',
82+
'fixed inset-0 z-40 bg-slate-900/45 backdrop-blur-[2px] transition-opacity duration-300',
8183
sidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
8284
)}
8385
onClick={() => setSidebarOpen(false)}
8486
/>
8587

86-
{/* 左侧滑出抽屉 — 弹簧动画 */}
88+
{/* 左侧滑出抽屉 */}
8789
<div
8890
className={cn(
89-
'fixed top-0 left-0 bottom-0 z-50 w-[85vw] max-w-[360px]',
90-
'sidebar-gradient flex flex-col',
91-
'shadow-xl'
91+
'sidebar-gradient fixed top-0 left-0 bottom-0 z-50 flex w-[88vw] max-w-[400px] flex-col',
92+
'overflow-hidden rounded-r-[28px] border-r border-white/80 shadow-xl'
9293
)}
9394
style={{
94-
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
95-
transition: 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
95+
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-104%)',
96+
transition: 'transform 0.38s cubic-bezier(0.34, 1.56, 0.64, 1)',
9697
willChange: 'transform',
98+
pointerEvents: sidebarOpen ? 'auto' : 'none',
9799
paddingTop: 'env(safe-area-inset-top, 0px)',
98100
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
99101
}}
100102
>
101-
{/* Header — compact single line */}
102-
<div className="header-gradient shrink-0 px-4 py-3 text-white flex items-center justify-between">
103-
<div className="flex items-center gap-2">
104-
<h1 className="text-lg font-bold">🗾 日本动漫店铺地图</h1>
105-
<span className="inline-flex items-center rounded-full bg-white/20 px-2 py-0.5 text-xs">
106-
📍 {locations.length}
107-
</span>
103+
{/* 品牌头部 */}
104+
<div className="header-gradient relative shrink-0 px-4 pb-3.5 pt-4 text-white">
105+
<div className="relative z-10 flex items-center justify-between gap-2">
106+
<div className="flex min-w-0 items-center gap-2.5">
107+
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/20 text-[17px] shadow-[inset_0_1px_0_rgba(255,255,255,0.35)]">
108+
🗾
109+
</div>
110+
<div className="min-w-0">
111+
<h1 className="truncate text-[15px] font-extrabold tracking-tight">
112+
日本动漫店铺地图
113+
</h1>
114+
<p className="mt-0.5 truncate text-[9px] font-semibold uppercase tracking-[0.16em] text-white/60">
115+
Otaku Store Map · {locations.length} stores
116+
</p>
117+
</div>
118+
</div>
119+
<button
120+
type="button"
121+
onClick={() => setSidebarOpen(false)}
122+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/20 transition-[background-color,transform] duration-200 hover:bg-white/35 active:scale-90"
123+
aria-label="关闭"
124+
>
125+
<X className="h-4 w-4" />
126+
</button>
108127
</div>
109-
<button
110-
type="button"
111-
onClick={() => setSidebarOpen(false)}
112-
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 active:scale-90 transition-all"
113-
aria-label="关闭"
114-
>
115-
<X className="h-4 w-4" />
116-
</button>
117128
</div>
118129

119130
{/* 搜索 + 筛选 + 地区 */}
120-
<div className="shrink-0 space-y-3 px-3 py-3 glass border-b border-[var(--color-border)]">
121-
{/* SearchBar + SortPopover 同行 */}
131+
<div className="shrink-0 space-y-2.5 border-b border-[var(--color-border)] bg-white/55 px-3 py-3">
122132
<div className="flex items-center gap-2">
123-
<div className="flex-1">
133+
<div className="min-w-0 flex-1">
124134
<SearchBar />
125135
</div>
126136
<SortPopover />
127137
</div>
128-
129-
{/* FilterPanel — compact dots */}
130-
<FilterPanel />
131-
132-
{/* RegionSelect — 地区下拉 */}
133-
<RegionSelect regionList={regionList} />
138+
<div className="flex items-center gap-2">
139+
<div className="min-w-0 flex-1">
140+
<FilterPanel locations={locations} />
141+
</div>
142+
<RegionSelect regionList={regionList} />
143+
</div>
134144
</div>
135145

136146
{/* 列表 */}
137-
<div className="flex-1 overflow-y-auto px-3 pt-2 pb-safe">
147+
<div className="flex-1 overflow-y-auto overscroll-contain px-3 pt-3 pb-safe">
138148
<CardList locations={filteredLocations} total={locations.length} />
139149
</div>
140150
</div>
141151

152+
{/* 底部结果栏 — 提升列表入口的可见性 */}
153+
{!sidebarOpen && (
154+
<button
155+
type="button"
156+
onClick={() => setSidebarOpen(true)}
157+
className={cn(
158+
'absolute left-3 right-[92px] z-[998] flex h-12 items-center justify-center gap-1.5',
159+
'rounded-2xl glass border border-[var(--color-border)] shadow-elevated',
160+
'text-[12.5px] font-semibold text-[var(--color-text)]',
161+
'transition-[transform,box-shadow] duration-200 active:scale-[0.97]'
162+
)}
163+
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 14px)' }}
164+
>
165+
<List className="h-4 w-4 text-[var(--color-accent)]" />
166+
{filteredLocations.length}/{locations.length} 个地点
167+
<ChevronRight className="h-3.5 w-3.5 text-[var(--color-text-dim)]" />
168+
</button>
169+
)}
170+
142171
{/* 导航面板 — 底部 Sheet */}
143172
<NavigationPanel />
144173
</div>

src/components/map/MapContainer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ export function MapView({ children }: MapViewProps) {
4343
maxZoom: isMobile ? 18 : MAX_ZOOM, // 移动端限制 maxZoom=18 以减少高缩放级别瓦片加载量,节省带宽与电量
4444
maxBounds: JAPAN_BOUNDS,
4545
attributionControl: false,
46-
pixelRatio: isMobile ? 1 : window.devicePixelRatio,
46+
pixelRatio: isMobile ? 1 : Math.min(window.devicePixelRatio || 1, 1.75),
4747
fadeDuration: 0,
4848
refreshExpiredTiles: false,
4949
// NOTE: localIdeographFontFamily depends on locally-installed CJK fonts.
5050
// PBF glyph fallback (localIdeographFontFamily: false) is disabled,
5151
// so missing CJK glyphs will render as tofu (□) on systems without
5252
// the specified fonts installed.
5353
localIdeographFontFamily: "'Noto Sans SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
54-
canvasContextAttributes: { antialias: false },
54+
canvasContextAttributes: { antialias: false, powerPreference: 'high-performance' },
5555
trackResize: true,
5656
collectResourceTiming: false,
5757
crossSourceCollisions: false,

0 commit comments

Comments
 (0)