a550d6d0fd
- Next.js + MapLibre GL 3D 地球 - CARTO dark-matter vector 底图 - 动态星空背景(闪烁、漂移、流星) - 经纬网格线(graticule) - 文物数据图层与朝代/类别筛选 - 中国风信息弹窗 - 中英双语 i18n - LayerPanel + MapControls 浮动面板
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import {
|
|
Settings,
|
|
RotateCcw,
|
|
Bookmark,
|
|
Film,
|
|
Share2,
|
|
Camera,
|
|
MessageSquare,
|
|
LayoutGrid,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Eye,
|
|
EyeOff,
|
|
} from 'lucide-react';
|
|
import { dynastyColors, categoryIcons } from '@/data/relics';
|
|
|
|
export interface LayerState {
|
|
dynasties: Record<string, boolean>;
|
|
categories: Record<string, boolean>;
|
|
clusters: boolean;
|
|
labels: boolean;
|
|
}
|
|
|
|
interface LayerPanelProps {
|
|
state: LayerState;
|
|
onChange: (state: LayerState) => void;
|
|
onReset: () => void;
|
|
}
|
|
|
|
export function LayerPanel({ state, onChange, onReset }: LayerPanelProps) {
|
|
const t = useTranslations();
|
|
const [dynastyOpen, setDynastyOpen] = useState(true);
|
|
const [categoryOpen, setCategoryOpen] = useState(true);
|
|
const [displayOpen, setDisplayOpen] = useState(false);
|
|
|
|
const allDynastiesVisible = Object.values(state.dynasties).every(Boolean);
|
|
const allCategoriesVisible = Object.values(state.categories).every(Boolean);
|
|
|
|
const toggleDynasty = (dyn: string) => {
|
|
onChange({
|
|
...state,
|
|
dynasties: { ...state.dynasties, [dyn]: !state.dynasties[dyn] },
|
|
});
|
|
};
|
|
|
|
const toggleAllDynasties = () => {
|
|
const next = !allDynastiesVisible;
|
|
onChange({
|
|
...state,
|
|
dynasties: Object.fromEntries(Object.keys(state.dynasties).map((k) => [k, next])),
|
|
});
|
|
};
|
|
|
|
const toggleCategory = (cat: string) => {
|
|
onChange({
|
|
...state,
|
|
categories: { ...state.categories, [cat]: !state.categories[cat] },
|
|
});
|
|
};
|
|
|
|
const toggleAllCategories = () => {
|
|
const next = !allCategoriesVisible;
|
|
onChange({
|
|
...state,
|
|
categories: Object.fromEntries(Object.keys(state.categories).map((k) => [k, next])),
|
|
});
|
|
};
|
|
|
|
const dynastyName = (dyn: string) => {
|
|
try {
|
|
return t(`dynasty.${dyn}`);
|
|
} catch {
|
|
return dyn;
|
|
}
|
|
};
|
|
|
|
const categoryName = (cat: string) => {
|
|
try {
|
|
return t(`category.${cat}`);
|
|
} catch {
|
|
return cat;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="absolute top-16 right-4 bottom-4 z-10 w-72 flex flex-col gap-2 pointer-events-none">
|
|
{/* Main panel */}
|
|
<div className="bg-[#06060c]/95 backdrop-blur-md border border-[var(--map-border)] rounded-xl shadow-2xl overflow-hidden flex flex-col pointer-events-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--map-border)]">
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded bg-blue-600/20 text-blue-400 border border-blue-600/30">
|
|
Beta
|
|
</span>
|
|
<h2 className="text-sm font-semibold text-[var(--map-text)]">{t('sidebar.operatingRelics')}</h2>
|
|
</div>
|
|
<button
|
|
onClick={() => setDisplayOpen(!displayOpen)}
|
|
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white/10 text-[var(--map-text-muted)] hover:text-white transition-colors"
|
|
aria-label="Display settings"
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Display settings dropdown */}
|
|
{displayOpen && (
|
|
<div className="px-3 py-2 border-b border-[var(--map-border)] bg-white/5">
|
|
<p className="text-xs text-[var(--map-text-muted)] leading-relaxed">
|
|
{t('sidebar.displayHint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Scrollable sections */}
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
|
{/* Dynasty section */}
|
|
<div className="rounded-lg border border-[var(--map-border)] overflow-hidden">
|
|
<div className="w-full flex items-center justify-between px-3 py-2 bg-white/5">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={toggleAllDynasties}
|
|
className={`w-6 h-6 rounded flex items-center justify-center text-xs transition-colors ${
|
|
allDynastiesVisible ? 'bg-blue-600 text-white' : 'bg-[var(--map-panel)] text-[var(--map-text-muted)]'
|
|
}`}
|
|
aria-label={allDynastiesVisible ? 'Hide all dynasties' : 'Show all dynasties'}
|
|
>
|
|
{allDynastiesVisible ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
|
|
</button>
|
|
<span className="text-sm font-medium text-[var(--map-text)]">{t('filter.dynasty')}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setDynastyOpen(!dynastyOpen)}
|
|
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white/10 transition-colors"
|
|
aria-label={dynastyOpen ? 'Collapse dynasty section' : 'Expand dynasty section'}
|
|
>
|
|
{dynastyOpen ? <ChevronUp className="w-4 h-4 text-[var(--map-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--map-text-muted)]" />}
|
|
</button>
|
|
</div>
|
|
|
|
{dynastyOpen && (
|
|
<div className="px-2 py-2 grid grid-cols-2 gap-1">
|
|
{Object.entries(dynastyColors).map(([dyn, color]) => (
|
|
<button
|
|
key={dyn}
|
|
onClick={() => toggleDynasty(dyn)}
|
|
className={`flex items-center gap-2 px-2 py-1.5 rounded-md text-xs transition-colors ${
|
|
state.dynasties[dyn] ? 'bg-white/10 text-white' : 'text-[var(--map-text-muted)] hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} />
|
|
<span className="truncate">{dynastyName(dyn)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Category section */}
|
|
<div className="rounded-lg border border-[var(--map-border)] overflow-hidden">
|
|
<div className="w-full flex items-center justify-between px-3 py-2 bg-white/5">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={toggleAllCategories}
|
|
className={`w-6 h-6 rounded flex items-center justify-center text-xs transition-colors ${
|
|
allCategoriesVisible ? 'bg-blue-600 text-white' : 'bg-[var(--map-panel)] text-[var(--map-text-muted)]'
|
|
}`}
|
|
aria-label={allCategoriesVisible ? 'Hide all categories' : 'Show all categories'}
|
|
>
|
|
{allCategoriesVisible ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
|
|
</button>
|
|
<span className="text-sm font-medium text-[var(--map-text)]">{t('filter.category')}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setCategoryOpen(!categoryOpen)}
|
|
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white/10 transition-colors"
|
|
aria-label={categoryOpen ? 'Collapse category section' : 'Expand category section'}
|
|
>
|
|
{categoryOpen ? <ChevronUp className="w-4 h-4 text-[var(--map-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--map-text-muted)]" />}
|
|
</button>
|
|
</div>
|
|
|
|
{categoryOpen && (
|
|
<div className="px-2 py-2 grid grid-cols-2 gap-1">
|
|
{Object.entries(categoryIcons).map(([cat, icon]) => (
|
|
<button
|
|
key={cat}
|
|
onClick={() => toggleCategory(cat)}
|
|
className={`flex items-center gap-2 px-2 py-1.5 rounded-md text-xs transition-colors ${
|
|
state.categories[cat] ? 'bg-white/10 text-white' : 'text-[var(--map-text-muted)] hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<span className="w-2.5 h-2.5 flex items-center justify-center text-[10px]">{icon}</span>
|
|
<span className="truncate">{categoryName(cat)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Clusters toggle */}
|
|
<button
|
|
onClick={() => onChange({ ...state, clusters: !state.clusters })}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg border border-[var(--map-border)] transition-colors ${
|
|
state.clusters ? 'bg-white/10 text-white' : 'bg-white/5 text-[var(--map-text-muted)] hover:bg-white/10'
|
|
}`}
|
|
>
|
|
<span className="text-sm font-medium">{t('sidebar.clustering')}</span>
|
|
<span className="text-xs">{state.clusters ? t('sidebar.on') : t('sidebar.off')}</span>
|
|
</button>
|
|
|
|
{/* Labels toggle */}
|
|
<button
|
|
onClick={() => onChange({ ...state, labels: !state.labels })}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg border border-[var(--map-border)] transition-colors ${
|
|
state.labels ? 'bg-white/10 text-white' : 'bg-white/5 text-[var(--map-text-muted)] hover:bg-white/10'
|
|
}`}
|
|
>
|
|
<span className="text-sm font-medium">{t('sidebar.labels')}</span>
|
|
<span className="text-xs">{state.labels ? t('sidebar.on') : t('sidebar.off')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Bottom actions */}
|
|
<div className="border-t border-[var(--map-border)] p-2 grid grid-cols-2 gap-1.5">
|
|
<ActionButton icon={<RotateCcw className="w-3.5 h-3.5" />} label={t('map.reset')} onClick={onReset} />
|
|
<ActionButton icon={<Bookmark className="w-3.5 h-3.5" />} label={t('sidebar.savedViews')} />
|
|
<ActionButton icon={<Film className="w-3.5 h-3.5" />} label={t('sidebar.cinematic')} />
|
|
<ActionButton icon={<Share2 className="w-3.5 h-3.5" />} label={t('sidebar.share')} />
|
|
<ActionButton icon={<Camera className="w-3.5 h-3.5" />} label={t('sidebar.screenshot')} />
|
|
<ActionButton icon={<MessageSquare className="w-3.5 h-3.5" />} label={t('sidebar.feedback')} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cards panel toggle */}
|
|
<button className="self-end flex items-center gap-2 px-3 py-2 rounded-lg bg-[#0b0f1e]/90 backdrop-blur-md border border-[var(--map-border)] text-sm text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors pointer-events-auto">
|
|
<LayoutGrid className="w-4 h-4" />
|
|
<span>{t('sidebar.cards')}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionButton({
|
|
icon,
|
|
label,
|
|
onClick,
|
|
disabled,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onClick?: () => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-[var(--map-panel)] hover:bg-[var(--map-panel-hover)] border border-[var(--map-border)] text-xs text-[var(--map-text-muted)] hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{icon}
|
|
<span className="truncate">{label}</span>
|
|
</button>
|
|
);
|
|
}
|