diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index a35afa2..cee7ca5 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -26,12 +26,12 @@ import dynamic from 'next/dynamic' import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons' import FilterBar from '@/components/dashboard/FilterBar' import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown' +import Chart from '@/components/dashboard/Chart' +import ContentStats from '@/components/dashboard/ContentStats' +import TopReferrers from '@/components/dashboard/TopReferrers' +import Locations from '@/components/dashboard/Locations' +import TechSpecs from '@/components/dashboard/TechSpecs' -const Chart = dynamic(() => import('@/components/dashboard/Chart')) -const ContentStats = dynamic(() => import('@/components/dashboard/ContentStats')) -const TopReferrers = dynamic(() => import('@/components/dashboard/TopReferrers')) -const Locations = dynamic(() => import('@/components/dashboard/Locations')) -const TechSpecs = dynamic(() => import('@/components/dashboard/TechSpecs')) const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats')) const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats')) const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth')) diff --git a/components/dashboard/DottedMap.tsx b/components/dashboard/DottedMap.tsx index f2c9bea..a9be59a 100644 --- a/components/dashboard/DottedMap.tsx +++ b/components/dashboard/DottedMap.tsx @@ -5,6 +5,55 @@ import { createMap } from 'svg-dotted-map' import { cn, formatNumber } from '@ciphera-net/ui' import { countryCentroids } from '@/lib/country-centroids' +// ─── Module-level constants ──────────────────────────────────────── +// Computed once when the module loads, survives component unmount/remount. +const MAP_WIDTH = 150 +const MAP_HEIGHT = 68 +const DOT_RADIUS = 0.25 + +const { points: MAP_POINTS, addMarkers } = createMap({ width: MAP_WIDTH, height: MAP_HEIGHT, mapSamples: 8000 }) + +// Pre-compute stagger helpers (row offsets for hex-grid pattern) +const _stagger = (() => { + const sorted = [...MAP_POINTS].sort((a, b) => a.y - b.y || a.x - b.x) + const rowMap = new Map() + let step = 0 + let prevY = Number.NaN + let prevXInRow = Number.NaN + + for (const p of sorted) { + if (p.y !== prevY) { + prevY = p.y + prevXInRow = Number.NaN + if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size) + } + if (!Number.isNaN(prevXInRow)) { + const delta = p.x - prevXInRow + if (delta > 0) step = step === 0 ? delta : Math.min(step, delta) + } + prevXInRow = p.x + } + + return { xStep: step || 1, yToRowIndex: rowMap } +})() + +// Pre-compute the base map dots as a single SVG path string (~8000 circles → 1 path) +const BASE_DOTS_PATH = (() => { + const r = DOT_RADIUS + const d = r * 2 + const parts: string[] = [] + for (const point of MAP_POINTS) { + const rowIndex = _stagger.yToRowIndex.get(point.y) ?? 0 + const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0 + const cx = point.x + offsetX + const cy = point.y + parts.push(`M${cx - r},${cy}a${r},${r} 0 1,0 ${d},0a${r},${r} 0 1,0 ${-d},0`) + } + return parts.join('') +})() + +// ─── Component ───────────────────────────────────────────────────── + interface DottedMapProps { data: Array<{ country: string; pageviews: number }> className?: string @@ -20,16 +69,8 @@ function getCountryName(code: string): string { } export default function DottedMap({ data, className }: DottedMapProps) { - const width = 150 - const height = 68 - const dotRadius = 0.25 const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null) - const { points, addMarkers } = useMemo( - () => createMap({ width, height, mapSamples: 8000 }), - [width, height], - ) - const markerData = useMemo(() => { if (!data.length) return [] @@ -47,55 +88,15 @@ export default function DottedMap({ data, className }: DottedMapProps) { })) }, [data]) - const markerInputs = useMemo( - () => markerData.map((d) => ({ lat: d.lat, lng: d.lng, size: d.size })), + const processedMarkers = useMemo( + () => addMarkers(markerData.map((d) => ({ lat: d.lat, lng: d.lng, size: d.size }))), [markerData], ) - const processedMarkers = useMemo(() => addMarkers(markerInputs), [addMarkers, markerInputs]) - - // Compute stagger helpers - const { xStep, yToRowIndex } = useMemo(() => { - const sorted = [...points].sort((a, b) => a.y - b.y || a.x - b.x) - const rowMap = new Map() - let step = 0 - let prevY = Number.NaN - let prevXInRow = Number.NaN - - for (const p of sorted) { - if (p.y !== prevY) { - prevY = p.y - prevXInRow = Number.NaN - if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size) - } - if (!Number.isNaN(prevXInRow)) { - const delta = p.x - prevXInRow - if (delta > 0) step = step === 0 ? delta : Math.min(step, delta) - } - prevXInRow = p.x - } - - return { xStep: step || 1, yToRowIndex: rowMap } - }, [points]) - - // Batch all 8000 base dots into a single instead of 8000 elements - const dotsPath = useMemo(() => { - const r = dotRadius - const d = r * 2 - const parts: string[] = [] - for (const point of points) { - const rowIndex = yToRowIndex.get(point.y) ?? 0 - const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0 - const cx = point.x + offsetX - const cy = point.y - parts.push(`M${cx - r},${cy}a${r},${r} 0 1,0 ${d},0a${r},${r} 0 1,0 ${-d},0`) - } - return parts.join('') - }, [points, dotRadius, xStep, yToRowIndex]) return (
@@ -110,18 +111,18 @@ export default function DottedMap({ data, className }: DottedMapProps) { {processedMarkers.map((marker, index) => { - const rowIndex = yToRowIndex.get(marker.y) ?? 0 - const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0 + const rowIndex = _stagger.yToRowIndex.get(marker.y) ?? 0 + const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0 const info = markerData[index] return (