Improve analytics UX with geo maps and readability updates
This commit is contained in:
@@ -1,34 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
} from 'chart.js'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
)
|
||||
|
||||
interface Props {
|
||||
content: SiteContent
|
||||
onSave: (c: SiteContent) => void
|
||||
|
||||
+20
-15
@@ -192,8 +192,8 @@
|
||||
|
||||
.word-with {
|
||||
color: var(--brand-warm-white);
|
||||
font-weight: 300;
|
||||
opacity: 0.85;
|
||||
font-weight: 400;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.rule-divider {
|
||||
@@ -413,11 +413,10 @@
|
||||
.episode-desc {
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: var(--brand-muted);
|
||||
margin: 0 0 0.6rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.episode-listen-cta {
|
||||
@@ -1190,7 +1189,7 @@
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
color: #6d6b66;
|
||||
color: #a09888;
|
||||
}
|
||||
|
||||
.contact-scripture-ref {
|
||||
@@ -2333,27 +2332,33 @@
|
||||
}
|
||||
|
||||
.admin-visits-table-scroll {
|
||||
overflow-x: visible;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #2a2518;
|
||||
}
|
||||
|
||||
.admin-visits-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
min-width: 620px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.admin-visits-table th,
|
||||
.admin-visits-table td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.16);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.1);
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-warm-white);
|
||||
font-size: 0.9rem;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin-visits-table tbody tr:hover {
|
||||
background: rgba(201, 168, 76, 0.04);
|
||||
}
|
||||
|
||||
.admin-visits-table th {
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { Line, Bar, Pie, Doughnut } from 'react-chartjs-2'
|
||||
import GeoMaps from './GeoMaps'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
} from 'chart.js'
|
||||
import type { AdminStats } from '../AdminPage'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
)
|
||||
|
||||
interface Props {
|
||||
stats: AdminStats | null
|
||||
statsStatus: 'loading' | 'error' | 'ready'
|
||||
@@ -49,13 +74,25 @@ export function AnalyticsPanel({
|
||||
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
|
||||
if (!stats) return null
|
||||
|
||||
// Normalize fields that may be missing from older data
|
||||
const realHits = stats.realHits ?? 0
|
||||
const botHits = stats.botHits ?? 0
|
||||
const totalHits = stats.totalHits ?? 0
|
||||
const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
|
||||
const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
|
||||
const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0
|
||||
const last30DaysBotTotal = stats.last30DaysBotTotal ?? 0
|
||||
const topPathsReal = stats.topPathsReal ?? stats.topPaths
|
||||
const topPathsBot = stats.topPathsBot ?? []
|
||||
const botReasons = stats.botReasons ?? []
|
||||
|
||||
// Chart data - Daily trend
|
||||
const dailyChartData = {
|
||||
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Real Visitors',
|
||||
data: stats.last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||
borderColor: '#c9a84c',
|
||||
backgroundColor: 'rgba(201, 168, 76, 0.1)',
|
||||
tension: 0.4,
|
||||
@@ -63,7 +100,7 @@ export function AnalyticsPanel({
|
||||
},
|
||||
{
|
||||
label: 'Bot Traffic',
|
||||
data: stats.last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
|
||||
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
|
||||
borderColor: '#666',
|
||||
backgroundColor: 'rgba(102, 102, 102, 0.1)',
|
||||
tension: 0.4,
|
||||
@@ -74,9 +111,9 @@ export function AnalyticsPanel({
|
||||
|
||||
// Chart data - Bot breakdown (pie)
|
||||
const botPieData = {
|
||||
labels: stats.botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||
datasets: [{
|
||||
data: stats.botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||
data: botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||
backgroundColor: [
|
||||
'#c9a84c',
|
||||
'#a0853d',
|
||||
@@ -98,7 +135,7 @@ export function AnalyticsPanel({
|
||||
const trafficRatioData = {
|
||||
labels: ['Real Visitors', 'Bot Traffic'],
|
||||
datasets: [{
|
||||
data: [stats.realHits, stats.botHits],
|
||||
data: [realHits, botHits],
|
||||
backgroundColor: ['#c9a84c', '#999'],
|
||||
borderColor: '#1a1a15',
|
||||
borderWidth: 2,
|
||||
@@ -106,9 +143,9 @@ export function AnalyticsPanel({
|
||||
}
|
||||
|
||||
// Top paths comparison
|
||||
const topPathsLabels = Array.from({ length: Math.max(stats.topPathsReal.length, stats.topPathsBot.length) }, (_, i: number) => {
|
||||
const realPath = stats.topPathsReal[i]?.path || ''
|
||||
const botPath = stats.topPathsBot[i]?.path || ''
|
||||
const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
|
||||
const realPath = topPathsReal[i]?.path || ''
|
||||
const botPath = topPathsBot[i]?.path || ''
|
||||
return realPath || botPath || `Path ${i + 1}`
|
||||
}).slice(0, 8)
|
||||
|
||||
@@ -117,12 +154,12 @@ export function AnalyticsPanel({
|
||||
datasets: [
|
||||
{
|
||||
label: 'Real Hits',
|
||||
data: topPathsLabels.map((_label, i) => stats.topPathsReal[i]?.hits || 0),
|
||||
data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
|
||||
backgroundColor: '#c9a84c',
|
||||
},
|
||||
{
|
||||
label: 'Bot Hits',
|
||||
data: topPathsLabels.map((_label, i) => stats.topPathsBot[i]?.hits || 0),
|
||||
data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
|
||||
backgroundColor: '#999',
|
||||
},
|
||||
],
|
||||
@@ -175,18 +212,18 @@ export function AnalyticsPanel({
|
||||
</article>
|
||||
<article>
|
||||
<h3>Real Traffic</h3>
|
||||
<p>{stats.realHits.toLocaleString()}</p>
|
||||
<small>{((stats.realHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
|
||||
<p>{realHits.toLocaleString()}</p>
|
||||
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Bot Traffic</h3>
|
||||
<p>{stats.botHits.toLocaleString()}</p>
|
||||
<small>{((stats.botHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
|
||||
<p>{botHits.toLocaleString()}</p>
|
||||
<small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Last 30 Days</h3>
|
||||
<p>{stats.last30DaysRealTotal.toLocaleString()}</p>
|
||||
<small>Real hits • {stats.last30DaysBotTotal.toLocaleString()} bots</small>
|
||||
<p>{last30DaysRealTotal.toLocaleString()}</p>
|
||||
<small>Real hits • {last30DaysBotTotal.toLocaleString()} bots</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Unique Visitors</h3>
|
||||
@@ -226,7 +263,7 @@ export function AnalyticsPanel({
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Bot Sources</h3>
|
||||
{stats.botReasons.length === 0 ? (
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bot traffic detected.</p>
|
||||
) : (
|
||||
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
@@ -234,11 +271,11 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Bot Detection Details</h3>
|
||||
{stats.botReasons.length === 0 ? (
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bots detected yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{stats.botReasons.map((reason: { reason: string; count: number }) => (
|
||||
{botReasons.map((reason: { reason: string; count: number }) => (
|
||||
<li key={reason.reason} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
|
||||
<span>{reason.reason.replace(/-/g, ' ')}</span>
|
||||
<strong style={{ color: '#c9a84c' }}>{reason.count.toLocaleString()}</strong>
|
||||
@@ -252,26 +289,15 @@ export function AnalyticsPanel({
|
||||
|
||||
{/* Geographic Charts */}
|
||||
{chartTab === 'geographic' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', marginBottom: '2rem' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Top Pages</h3>
|
||||
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
|
||||
</div>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Top Countries</h3>
|
||||
{stats.visitors.topCountries.length === 0 ? (
|
||||
<p className="admin-stats-note">No visitor data yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{stats.visitors.topCountries.map((country: { name: string; hits: number }) => (
|
||||
<li key={country.name} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
|
||||
<span>{country.name}</span>
|
||||
<strong style={{ color: '#c9a84c' }}>{country.hits.toLocaleString()}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<GeoMaps
|
||||
topCountries={stats.visitors.topCountries}
|
||||
topStates={stats.visitors.topStates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -290,8 +316,22 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-lists">
|
||||
<div><h3>Top Countries</h3><ul>{stats.visitors.topCountries.map((item: { name: string; hits: number }) => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
||||
<div><h3>Top States</h3><ul>{stats.visitors.topStates.map((item: { name: string; hits: number }) => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
||||
<div>
|
||||
<h3>Top Countries</h3>
|
||||
<ul>
|
||||
{stats.visitors.topCountries.map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Top States</h3>
|
||||
<ul>
|
||||
{stats.visitors.topStates.map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-visits-table-wrap">
|
||||
@@ -302,12 +342,36 @@ export function AnalyticsPanel({
|
||||
<div className="admin-visits-table-scroll">
|
||||
<table className="admin-visits-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>IP</th><th>Country</th><th>Path</th><th>Returning</th><th>Visit #</th></tr>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>IP</th>
|
||||
<th>Country</th>
|
||||
<th>Path</th>
|
||||
<th>Returning</th>
|
||||
<th>Visit #</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.visitors.recentVisits.map(row => (
|
||||
<tr key={`${row.visitorId}-${row.at}`}>
|
||||
<td>{formatDate(row.at)}</td><td>{maskIp(row.ip)}</td><td>{row.country}</td><td>{row.path}</td><td>{row.returningVisitor ? 'Yes' : 'No'}</td><td>{row.visitCount}</td>
|
||||
<td>{formatDate(row.at)}</td>
|
||||
<td><code style={{ fontSize: '0.8rem', color: '#aaa' }}>{maskIp(row.ip)}</code></td>
|
||||
<td>{row.country || '—'}</td>
|
||||
<td style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={row.path}>{row.path}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.1rem 0.5rem',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.75rem',
|
||||
backgroundColor: row.returningVisitor ? '#1a3a1a' : '#1a1a15',
|
||||
color: row.returningVisitor ? '#6dbf6d' : '#888',
|
||||
border: `1px solid ${row.returningVisitor ? '#2a5a2a' : '#333'}`,
|
||||
}}>
|
||||
{row.returningVisitor ? 'Returning' : 'New'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: '#c9a84c' }}>{row.visitCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { ComposableMap, Geographies, Geography } from 'react-simple-maps'
|
||||
|
||||
const WORLD_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'
|
||||
const US_URL = 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json'
|
||||
|
||||
// Normalize topojson country names to match what geolocation APIs typically return
|
||||
const COUNTRY_ALIASES: Record<string, string> = {
|
||||
'united states of america': 'united states',
|
||||
'russian federation': 'russia',
|
||||
'republic of korea': 'south korea',
|
||||
"democratic people's republic of korea": 'north korea',
|
||||
'united kingdom of great britain and northern ireland': 'united kingdom',
|
||||
'iran (islamic republic of)': 'iran',
|
||||
'syrian arab republic': 'syria',
|
||||
'bolivarian republic of venezuela': 'venezuela',
|
||||
'plurinational state of bolivia': 'bolivia',
|
||||
'united republic of tanzania': 'tanzania',
|
||||
'democratic republic of the congo': 'dr congo',
|
||||
"lao people's democratic republic": 'laos',
|
||||
'viet nam': 'vietnam',
|
||||
'taiwan, province of china': 'taiwan',
|
||||
}
|
||||
|
||||
function normalizeCountry(name: string): string {
|
||||
const lower = name.toLowerCase()
|
||||
return COUNTRY_ALIASES[lower] ?? lower
|
||||
}
|
||||
|
||||
function getColor(hits: number, max: number, baseColor = '#1e1e18'): string {
|
||||
if (!hits || max === 0) return baseColor
|
||||
const t = Math.sqrt(hits / max) // sqrt for better visual spread
|
||||
const r = Math.round(30 + (201 - 30) * t)
|
||||
const g = Math.round(30 + (168 - 30) * t)
|
||||
const b = Math.round(21 + (76 - 21) * t)
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
name: string
|
||||
hits: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface GeoMapsProps {
|
||||
topCountries: Array<{ name: string; hits: number }>
|
||||
topStates: Array<{ name: string; hits: number }>
|
||||
}
|
||||
|
||||
export default function GeoMaps({ topCountries, topStates }: GeoMapsProps) {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
||||
|
||||
const countryMap = new Map(topCountries.map(c => [c.name.toLowerCase(), c.hits]))
|
||||
const stateMap = new Map(topStates.map(s => [s.name.toLowerCase(), s.hits]))
|
||||
const maxCountry = Math.max(...topCountries.map(c => c.hits), 1)
|
||||
const maxState = Math.max(...topStates.map(s => s.hits), 1)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
|
||||
{/* World map */}
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', position: 'relative' }}>
|
||||
<h3 style={{ marginBottom: '0.75rem', color: '#c9a84c', fontFamily: 'var(--brand-font-body)', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: '0.85rem' }}>
|
||||
Visitors by Country
|
||||
</h3>
|
||||
{topCountries.length === 0 && (
|
||||
<p style={{ color: '#b0a48c', fontSize: '0.875rem' }}>No country data yet.</p>
|
||||
)}
|
||||
<ComposableMap
|
||||
projectionConfig={{ scale: 145, center: [0, 10] }}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
>
|
||||
<Geographies geography={WORLD_URL}>
|
||||
{({ geographies }) =>
|
||||
geographies.map(geo => {
|
||||
const geoName = geo.properties?.name ?? ''
|
||||
const normalized = normalizeCountry(geoName)
|
||||
const hits = countryMap.get(normalized) ?? countryMap.get(geoName.toLowerCase()) ?? 0
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey ?? geoName}
|
||||
geography={geo}
|
||||
fill={getColor(hits, maxCountry)}
|
||||
stroke="#0a0a08"
|
||||
strokeWidth={0.4}
|
||||
style={{
|
||||
default: { outline: 'none' },
|
||||
hover: { outline: 'none', fill: hits ? '#e0c070' : '#2a2a22', cursor: 'default' },
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
onMouseEnter={(e) => setTooltip({ name: geoName, hits, x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={(e) => setTooltip(t => t ? { ...t, x: e.clientX, y: e.clientY } : null)}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ComposableMap>
|
||||
|
||||
{/* Color legend */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#888' }}>0</span>
|
||||
<div style={{ flex: 1, height: '8px', borderRadius: '4px', background: 'linear-gradient(to right, #1e1e18, rgb(201,168,76))' }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#888' }}>{maxCountry.toLocaleString()} visits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* US states map */}
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', position: 'relative' }}>
|
||||
<h3 style={{ marginBottom: '0.75rem', color: '#c9a84c', fontFamily: 'var(--brand-font-body)', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: '0.85rem' }}>
|
||||
Visitors by US State
|
||||
</h3>
|
||||
{topStates.length === 0 && (
|
||||
<p style={{ color: '#b0a48c', fontSize: '0.875rem' }}>No state data yet.</p>
|
||||
)}
|
||||
<ComposableMap
|
||||
projection="geoAlbersUsa"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
>
|
||||
<Geographies geography={US_URL}>
|
||||
{({ geographies }) =>
|
||||
geographies.map(geo => {
|
||||
const geoName = geo.properties?.name ?? ''
|
||||
const hits = stateMap.get(geoName.toLowerCase()) ?? 0
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey ?? geoName}
|
||||
geography={geo}
|
||||
fill={getColor(hits, maxState)}
|
||||
stroke="#0a0a08"
|
||||
strokeWidth={0.8}
|
||||
style={{
|
||||
default: { outline: 'none' },
|
||||
hover: { outline: 'none', fill: hits ? '#e0c070' : '#2a2a22', cursor: 'default' },
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
onMouseEnter={(e) => setTooltip({ name: geoName, hits, x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={(e) => setTooltip(t => t ? { ...t, x: e.clientX, y: e.clientY } : null)}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ComposableMap>
|
||||
|
||||
{/* Color legend */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#888' }}>0</span>
|
||||
<div style={{ flex: 1, height: '8px', borderRadius: '4px', background: 'linear-gradient(to right, #1e1e18, rgb(201,168,76))' }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#888' }}>{maxState.toLocaleString()} visits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
left: tooltip.x + 14,
|
||||
top: tooltip.y - 36,
|
||||
backgroundColor: '#111',
|
||||
border: '1px solid #c9a84c',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 10px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
fontSize: '0.8rem',
|
||||
color: '#f0ead8',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
<strong style={{ color: '#c9a84c' }}>{tooltip.name}</strong>
|
||||
{' — '}
|
||||
{tooltip.hits > 0 ? <>{tooltip.hits.toLocaleString()} visit{tooltip.hits !== 1 ? 's' : ''}</> : 'no visits'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -11,11 +11,11 @@
|
||||
--brand-gold-light: #e0c070;
|
||||
--brand-gold-dark: #8a6e28;
|
||||
--brand-warm-white: #f0ead8;
|
||||
--brand-muted: #7a7060;
|
||||
--brand-muted: #b0a48c;
|
||||
--brand-font-body: 'Cormorant Garamond', Georgia, serif;
|
||||
--brand-font-heading: 'Cormorant Garamond', Georgia, serif;
|
||||
font-family: var(--brand-font-body);
|
||||
line-height: 1.5;
|
||||
line-height: 1.6;
|
||||
color: var(--brand-warm-white);
|
||||
background: var(--brand-black);
|
||||
font-synthesis: none;
|
||||
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
declare module 'react-simple-maps' {
|
||||
import { ComponentType, ReactNode, CSSProperties, MouseEvent } from 'react'
|
||||
|
||||
interface ComposableMapProps {
|
||||
projection?: string
|
||||
projectionConfig?: Record<string, unknown>
|
||||
style?: CSSProperties
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
interface GeographiesProps {
|
||||
geography: string | object
|
||||
children: (props: { geographies: GeoFeature[] }) => ReactNode
|
||||
}
|
||||
|
||||
interface GeoFeature {
|
||||
rsmKey?: string
|
||||
id?: string | number
|
||||
properties: Record<string, string>
|
||||
geometry?: object
|
||||
}
|
||||
|
||||
interface GeographyProps {
|
||||
key?: string
|
||||
geography: GeoFeature
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
style?: {
|
||||
default?: CSSProperties
|
||||
hover?: CSSProperties
|
||||
pressed?: CSSProperties
|
||||
}
|
||||
onMouseEnter?: (e: MouseEvent<SVGPathElement>) => void
|
||||
onMouseMove?: (e: MouseEvent<SVGPathElement>) => void
|
||||
onMouseLeave?: (e: MouseEvent<SVGPathElement>) => void
|
||||
onClick?: (e: MouseEvent<SVGPathElement>) => void
|
||||
}
|
||||
|
||||
export const ComposableMap: ComponentType<ComposableMapProps>
|
||||
export const Geographies: ComponentType<GeographiesProps>
|
||||
export const Geography: ComponentType<GeographyProps>
|
||||
export const ZoomableGroup: ComponentType<{ children?: ReactNode; [key: string]: unknown }>
|
||||
export const Marker: ComponentType<{ coordinates: [number, number]; children?: ReactNode; [key: string]: unknown }>
|
||||
}
|
||||
Reference in New Issue
Block a user