250 lines
8.9 KiB
TypeScript
250 lines
8.9 KiB
TypeScript
import { useEffect, useState, useMemo, useRef } from 'react';
|
|
import * as d3Geo from 'd3-geo';
|
|
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { getMalaysiaStateByCodeOrName, type MalaysiaStateInfo } from '../../utils/malaysiaGeoUtils';
|
|
|
|
interface StateSalesData {
|
|
code: string;
|
|
name: string;
|
|
revenue?: number;
|
|
orders?: number;
|
|
}
|
|
|
|
interface MalaysiaMapSvgProps {
|
|
statesData?: Record<string, StateSalesData>;
|
|
selectedState: string | null;
|
|
onSelectState: (stateName: string) => void;
|
|
}
|
|
|
|
export default function MalaysiaMapSvg({
|
|
statesData: _statesData,
|
|
selectedState,
|
|
onSelectState,
|
|
}: MalaysiaMapSvgProps) {
|
|
const { t, i18n } = useTranslation();
|
|
const [geoData, setGeoData] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [hoveredFeature, setHoveredFeature] = useState<any>(null);
|
|
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
|
|
|
// Zoom & Pan state
|
|
const [zoom, setZoom] = useState(1);
|
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragStartRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Malaysia coordinates span wider horizontally (West + East Malaysia)
|
|
const width = 800;
|
|
const height = 450;
|
|
|
|
useEffect(() => {
|
|
fetch('/maps/malaysia-states.json')
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
setGeoData(data);
|
|
})
|
|
.catch((err) => console.error('Failed to load Malaysia GeoJSON:', err))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
// D3 Projection and Path Generator
|
|
const paths = useMemo(() => {
|
|
if (!geoData || !geoData.features) return [];
|
|
|
|
const proj = d3Geo.geoMercator().fitExtent(
|
|
[
|
|
[25, 25],
|
|
[width - 25, height - 25],
|
|
],
|
|
geoData
|
|
);
|
|
|
|
const pathGen = d3Geo.geoPath().projection(proj);
|
|
|
|
return geoData.features.map((feature: any) => {
|
|
const stateName = feature.properties?.name || '';
|
|
const stateCode = feature.properties?.state || '';
|
|
const d = pathGen(feature);
|
|
return {
|
|
feature,
|
|
stateName,
|
|
stateCode,
|
|
d,
|
|
};
|
|
});
|
|
}, [geoData]);
|
|
|
|
// Zoom controls
|
|
const handleZoomIn = () => setZoom((z) => Math.min(4, z + 0.3));
|
|
const handleZoomOut = () => setZoom((z) => Math.max(0.8, z - 0.3));
|
|
const handleResetZoom = () => {
|
|
setZoom(1);
|
|
setPan({ x: 0, y: 0 });
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (e.button !== 0) return;
|
|
setIsDragging(true);
|
|
dragStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (containerRef.current) {
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
setTooltipPos({
|
|
x: e.clientX - rect.left,
|
|
y: e.clientY - rect.top,
|
|
});
|
|
}
|
|
|
|
if (!isDragging) return;
|
|
setPan({
|
|
x: e.clientX - dragStartRef.current.x,
|
|
y: e.clientY - dragStartRef.current.y,
|
|
});
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-[520px] bg-slate-900/40 rounded-2xl border border-white/5 animate-pulse text-slate-400">
|
|
<div className="w-8 h-8 border-2 border-emerald-400 border-t-transparent rounded-full animate-spin mb-3" />
|
|
<p className="text-sm">กำลังโหลดแผนที่ประเทศมาเลเซีย (Loading Malaysia Map)...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="relative w-full h-[520px] md:h-[600px] bg-gradient-to-b from-slate-950/60 to-slate-900/40 rounded-2xl border border-white/10 overflow-hidden flex items-center justify-center select-none"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={() => {
|
|
setIsDragging(false);
|
|
setHoveredFeature(null);
|
|
}}
|
|
>
|
|
{/* Zoom / Pan Controls Overlay */}
|
|
<div className="absolute top-4 right-4 z-20 flex flex-col gap-1.5 bg-slate-900/80 backdrop-blur-md p-1.5 rounded-xl border border-white/10 shadow-xl">
|
|
<button
|
|
onClick={handleZoomIn}
|
|
title={t('salesMap.zoomIn', 'Zoom In')}
|
|
className="p-2 rounded-lg bg-white/5 hover:bg-emerald-500/20 text-slate-300 hover:text-emerald-400 transition-colors"
|
|
>
|
|
<ZoomIn className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={handleZoomOut}
|
|
title={t('salesMap.zoomOut', 'Zoom Out')}
|
|
className="p-2 rounded-lg bg-white/5 hover:bg-emerald-500/20 text-slate-300 hover:text-emerald-400 transition-colors"
|
|
>
|
|
<ZoomOut className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={handleResetZoom}
|
|
title={t('salesMap.resetZoom', 'Reset View')}
|
|
className="p-2 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-300 hover:text-rose-400 transition-colors"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* SVG Canvas */}
|
|
<svg
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className="w-full h-full cursor-grab active:cursor-grabbing transition-transform duration-75"
|
|
>
|
|
<g
|
|
transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}
|
|
transform-origin={`${width / 2} ${height / 2}`}
|
|
>
|
|
{paths.map(({ feature, stateName, stateCode, d }: any) => {
|
|
if (!d) return null;
|
|
const isSelected = selectedState === stateName;
|
|
const isHovered = hoveredFeature?.properties?.name === stateName;
|
|
const fillColor = isSelected
|
|
? '#10b981'
|
|
: isHovered
|
|
? '#334155'
|
|
: '#1e293b';
|
|
|
|
return (
|
|
<path
|
|
key={feature.id || stateCode || stateName}
|
|
d={d}
|
|
fill={fillColor}
|
|
stroke={isSelected ? '#34d399' : isHovered ? '#ffffff' : '#0f172a'}
|
|
strokeWidth={isSelected ? 2.5 / zoom : isHovered ? 1.8 / zoom : 0.8 / zoom}
|
|
className="transition-all duration-150 cursor-pointer"
|
|
style={{
|
|
filter: isSelected
|
|
? 'drop-shadow(0 0 8px rgba(16, 185, 129, 0.85))'
|
|
: isHovered
|
|
? 'brightness(1.25)'
|
|
: undefined,
|
|
}}
|
|
onMouseEnter={() => setHoveredFeature(feature)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelectState(stateName);
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</g>
|
|
</svg>
|
|
|
|
{/* Hover Tooltip */}
|
|
{hoveredFeature && tooltipPos && (() => {
|
|
const nameEn = hoveredFeature.properties?.name || '';
|
|
const code = hoveredFeature.properties?.state || '';
|
|
const meta: MalaysiaStateInfo | undefined = getMalaysiaStateByCodeOrName(nameEn);
|
|
const displayName = i18n.language === 'th' && meta?.nameTh ? meta.nameTh : nameEn;
|
|
|
|
return (
|
|
<div
|
|
className="absolute pointer-events-none z-30 bg-slate-900/95 backdrop-blur-md border border-emerald-500/30 rounded-xl px-3.5 py-2.5 shadow-2xl text-xs flex flex-col gap-1 transition-transform duration-75 min-w-[170px]"
|
|
style={{
|
|
left: `${Math.min(tooltipPos.x + 15, (containerRef.current?.clientWidth || 500) - 200)}px`,
|
|
top: `${Math.min(tooltipPos.y - 10, (containerRef.current?.clientHeight || 600) - 90)}px`,
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="font-bold text-white text-sm">
|
|
{displayName}
|
|
</span>
|
|
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 font-mono px-1.5 py-0.5 rounded border border-emerald-500/30">
|
|
{code}
|
|
</span>
|
|
</div>
|
|
{i18n.language === 'th' && meta?.name && (
|
|
<div className="text-[10px] text-slate-400">
|
|
{nameEn} ({meta.capital})
|
|
</div>
|
|
)}
|
|
{meta?.region && (
|
|
<div className="text-[10px] text-slate-400 font-medium">
|
|
โซน: <span className="text-emerald-300">{meta.region}</span>
|
|
</div>
|
|
)}
|
|
<div className="h-px bg-white/10 my-0.5" />
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-slate-400">สถานะ:</span>
|
|
<span className="text-[10px] font-medium text-amber-400 bg-amber-400/10 px-1.5 py-0.5 rounded border border-amber-400/20">
|
|
พร้อมรับข้อมูลยอดขาย
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|