Built the Oracle Tab (#14)
This commit is contained in:
115
app/src/oracle/components/renderers/GeoMapRenderer.tsx
Normal file
115
app/src/oracle/components/renderers/GeoMapRenderer.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user