Files
freedakgmail a550d6d0fd feat: Heritage Globe - 中国文物全球分布地图
- Next.js + MapLibre GL 3D 地球
- CARTO dark-matter vector 底图
- 动态星空背景(闪烁、漂移、流星)
- 经纬网格线(graticule)
- 文物数据图层与朝代/类别筛选
- 中国风信息弹窗
- 中英双语 i18n
- LayerPanel + MapControls 浮动面板
2026-07-01 01:37:18 +08:00

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>
);
}