116 lines
4.9 KiB
TypeScript
116 lines
4.9 KiB
TypeScript
import { motion } from 'framer-motion';
|
|
import type { CanvasComponent } from '../../types/canvas';
|
|
import { RendererWrapper, NoDataPlaceholder, type ComponentRenderContext } from './RendererWrapper';
|
|
|
|
interface Props { component: CanvasComponent; ctx: ComponentRenderContext }
|
|
|
|
interface GeoRow {
|
|
district: string;
|
|
lat?: number;
|
|
lng?: number;
|
|
lead_count?: number;
|
|
count?: number;
|
|
avg_qd_score?: number;
|
|
x?: number;
|
|
y?: number;
|
|
}
|
|
|
|
export function GeoMapRenderer({ component, ctx }: Props) {
|
|
const rows = (component.dataRows ?? []) as unknown as GeoRow[];
|
|
const intensityField = (component.visualizationParameters as { intensityField?: string }).intensityField ?? 'lead_count';
|
|
const tooltipFields = (component.visualizationParameters as { tooltipFields?: string[] }).tooltipFields ?? ['district', 'lead_count'];
|
|
|
|
if (!rows.length) return (
|
|
<RendererWrapper component={component} ctx={ctx} minHeight={360}>
|
|
<NoDataPlaceholder message="No geographic data available." />
|
|
</RendererWrapper>
|
|
);
|
|
|
|
const maxVal = Math.max(...rows.map((r) => Number(r[intensityField as keyof GeoRow] ?? r.count ?? r.lead_count ?? 0)));
|
|
|
|
return (
|
|
<RendererWrapper component={component} ctx={ctx} minHeight={400}>
|
|
<div className="relative w-full h-[280px] rounded-xl overflow-hidden" style={{
|
|
background: 'radial-gradient(ellipse at center, rgba(14,116,144,0.12) 0%, rgba(10,12,20,0.95) 100%)',
|
|
border: '1px solid rgba(59,130,246,0.12)',
|
|
}}>
|
|
{/* Dubai grid lines (decorative) */}
|
|
<svg className="absolute inset-0 w-full h-full opacity-10" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#3B82F6" strokeWidth="0.5" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
</svg>
|
|
|
|
{/* Coastline glow */}
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 h-24 pointer-events-none"
|
|
style={{ background: 'linear-gradient(to top, rgba(14,116,144,0.15) 0%, transparent 100%)' }}
|
|
/>
|
|
|
|
{/* Legend */}
|
|
<div
|
|
className="absolute left-3 top-3 z-10 px-3 py-2.5 rounded-xl text-xs"
|
|
style={{ background: 'rgba(10,12,20,0.88)', border: '1px solid rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)' }}
|
|
>
|
|
<p className="text-zinc-500 uppercase tracking-wider text-[10px] mb-2">Lead Intensity</p>
|
|
{[
|
|
{ label: 'High', color: '#0EA5E9' },
|
|
{ label: 'Medium', color: '#22D3EE' },
|
|
{ label: 'Low', color: '#164E63' },
|
|
].map(({ label, color }) => (
|
|
<div key={label} className="flex items-center gap-2 mb-1">
|
|
<div className="w-2 h-2 rounded-full" style={{ background: color, boxShadow: `0 0 8px ${color}` }} />
|
|
<span className="text-zinc-300">{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* District pins */}
|
|
{rows.map((row, i) => {
|
|
const val = Number(row[intensityField as keyof GeoRow] ?? row.count ?? row.lead_count ?? 0);
|
|
const ratio = maxVal > 0 ? val / maxVal : 0;
|
|
const color = ratio > 0.7 ? '#0EA5E9' : ratio > 0.4 ? '#22D3EE' : '#164E63';
|
|
const size = 6 + ratio * 14;
|
|
const x = row.x ?? (20 + i * 12);
|
|
const y = row.y ?? (30 + i * 8);
|
|
|
|
return (
|
|
<motion.div
|
|
key={row.district}
|
|
className="absolute group cursor-pointer"
|
|
style={{ left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)' }}
|
|
initial={{ scale: 0, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
transition={{ delay: i * 0.08, type: 'spring', damping: 10 }}
|
|
>
|
|
<motion.div
|
|
className="rounded-full"
|
|
style={{
|
|
width: size,
|
|
height: size,
|
|
background: color,
|
|
boxShadow: `0 0 ${size * 2}px ${color}88`,
|
|
}}
|
|
whileHover={{ scale: 1.5 }}
|
|
/>
|
|
{/* Tooltip */}
|
|
<div
|
|
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1.5 rounded-lg text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-30"
|
|
style={{ background: 'rgba(10,12,20,0.95)', border: '1px solid rgba(59,130,246,0.2)', backdropFilter: 'blur(8px)' }}
|
|
>
|
|
<p className="font-semibold text-zinc-100">{row.district}</p>
|
|
{tooltipFields.map((f) => (
|
|
<p key={f} className="text-zinc-400 capitalize">{f.replace(/_/g, ' ')}: {String(row[f as keyof GeoRow] ?? '—')}</p>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</RendererWrapper>
|
|
);
|
|
}
|