Implement read-only lineage view that traces Pokemon across genlocke legs via existing transfer records. Backend walks transfer chains to build lineage entries; frontend renders them as cards with a column-aligned timeline grid so leg dots line up vertically across all lineages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
277 lines
8.7 KiB
TypeScript
277 lines
8.7 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useGenlockeLineages } from '../hooks/useGenlockes'
|
|
import type { LineageEntry, LineageLegEntry } from '../types'
|
|
|
|
interface GenlockeLineageProps {
|
|
genlockeId: number
|
|
}
|
|
|
|
function LegDot({ leg }: { leg: LineageLegEntry }) {
|
|
let color: string
|
|
let label: string
|
|
|
|
if (leg.faintLevel !== null) {
|
|
color = 'bg-red-500'
|
|
label = 'Dead'
|
|
} else if (leg.wasTransferred) {
|
|
color = 'bg-blue-500'
|
|
label = 'Transferred'
|
|
} else if (leg.enteredHof) {
|
|
color = 'bg-yellow-500'
|
|
label = 'Hall of Fame'
|
|
} else {
|
|
color = 'bg-green-500'
|
|
label = 'Alive'
|
|
}
|
|
|
|
const displayPokemon = leg.currentPokemon ?? leg.pokemon
|
|
|
|
return (
|
|
<div className="group relative flex flex-col items-center">
|
|
<div className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 ring-gray-200 dark:ring-gray-600`} />
|
|
|
|
{/* Tooltip */}
|
|
<div className="absolute bottom-full mb-2 hidden group-hover:flex flex-col items-center z-10">
|
|
<div className="bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg px-3 py-2 whitespace-nowrap shadow-lg space-y-1">
|
|
<div className="font-semibold">{leg.gameName}</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{displayPokemon.spriteUrl && (
|
|
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-6 h-6" />
|
|
)}
|
|
<span>{displayPokemon.name}</span>
|
|
</div>
|
|
{leg.catchLevel !== null && (
|
|
<div>Caught Lv. {leg.catchLevel}</div>
|
|
)}
|
|
{leg.faintLevel !== null && (
|
|
<div className="text-red-300">Died Lv. {leg.faintLevel}</div>
|
|
)}
|
|
{leg.deathCause && (
|
|
<div className="text-red-300 italic">{leg.deathCause}</div>
|
|
)}
|
|
<div className={`font-medium ${
|
|
leg.faintLevel !== null ? 'text-red-300' :
|
|
leg.wasTransferred ? 'text-blue-300' :
|
|
leg.enteredHof ? 'text-yellow-300' :
|
|
'text-green-300'
|
|
}`}>
|
|
{label}
|
|
</div>
|
|
{leg.enteredHof && leg.faintLevel === null && (
|
|
<div className="text-yellow-300">Hall of Fame</div>
|
|
)}
|
|
</div>
|
|
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TimelineGrid({
|
|
lineage,
|
|
allLegOrders,
|
|
}: {
|
|
lineage: LineageEntry
|
|
allLegOrders: number[]
|
|
}) {
|
|
const legMap = new Map(lineage.legs.map((l) => [l.legOrder, l]))
|
|
const minLeg = lineage.legs[0].legOrder
|
|
const maxLeg = lineage.legs[lineage.legs.length - 1].legOrder
|
|
|
|
return (
|
|
<div
|
|
className="grid"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${allLegOrders.length}, minmax(48px, 1fr))`,
|
|
}}
|
|
>
|
|
{allLegOrders.map((legOrder, i) => {
|
|
const leg = legMap.get(legOrder)
|
|
const inRange = legOrder >= minLeg && legOrder <= maxLeg
|
|
const showLeftLine = inRange && i > 0 && allLegOrders[i - 1] >= minLeg
|
|
const showRightLine =
|
|
inRange &&
|
|
i < allLegOrders.length - 1 &&
|
|
allLegOrders[i + 1] <= maxLeg
|
|
|
|
return (
|
|
<div
|
|
key={legOrder}
|
|
className="flex justify-center relative"
|
|
style={{ height: '20px' }}
|
|
>
|
|
{/* Left half connector */}
|
|
{showLeftLine && (
|
|
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
|
)}
|
|
{/* Right half connector */}
|
|
{showRightLine && (
|
|
<div className="absolute top-[9px] left-1/2 right-0 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
|
)}
|
|
{/* Dot or empty */}
|
|
{leg ? (
|
|
<div className="relative z-10">
|
|
<LegDot leg={leg} />
|
|
</div>
|
|
) : (
|
|
<div className="h-4" />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LineageCard({
|
|
lineage,
|
|
allLegOrders,
|
|
}: {
|
|
lineage: LineageEntry
|
|
allLegOrders: number[]
|
|
}) {
|
|
const firstLeg = lineage.legs[0]
|
|
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center gap-4">
|
|
{/* Left: Pokemon sprite + nickname */}
|
|
<div className="flex flex-col items-center min-w-[80px]">
|
|
{displayPokemon.spriteUrl ? (
|
|
<img
|
|
src={displayPokemon.spriteUrl}
|
|
alt={displayPokemon.name}
|
|
className="w-16 h-16"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
|
{displayPokemon.name[0].toUpperCase()}
|
|
</div>
|
|
)}
|
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center">
|
|
{lineage.nickname || lineage.pokemon.name}
|
|
</span>
|
|
{lineage.nickname && (
|
|
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
|
{lineage.pokemon.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Center: Timeline */}
|
|
<div className="flex-1 overflow-x-auto py-2">
|
|
<TimelineGrid lineage={lineage} allLegOrders={allLegOrders} />
|
|
</div>
|
|
|
|
{/* Right: Status badge */}
|
|
<div className="shrink-0">
|
|
<span
|
|
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
|
lineage.status === 'alive'
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
|
|
: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
|
}`}
|
|
>
|
|
{lineage.status === 'alive' ? 'Alive' : 'Dead'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|
const { data, isLoading, error } = useGenlockeLineages(genlockeId)
|
|
|
|
const allLegOrders = useMemo(() => {
|
|
if (!data) return []
|
|
return [...new Set(data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder)))].sort(
|
|
(a, b) => a - b
|
|
)
|
|
}, [data])
|
|
|
|
const legGameNames = useMemo(() => {
|
|
if (!data) return new Map<number, string>()
|
|
const map = new Map<number, string>()
|
|
for (const lineage of data.lineages) {
|
|
for (const leg of lineage.legs) {
|
|
map.set(leg.legOrder, leg.gameName)
|
|
}
|
|
}
|
|
return map
|
|
}, [data])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="w-6 h-6 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
|
Failed to load lineage data.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!data || data.totalLineages === 0) {
|
|
return (
|
|
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-6 text-center text-gray-500 dark:text-gray-400">
|
|
No Pokemon have been transferred between legs yet.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Summary bar */}
|
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
|
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
|
|
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Column header row */}
|
|
<div className="flex items-center gap-4 px-4">
|
|
{/* Spacer matching pokemon info column */}
|
|
<div className="min-w-[80px]" />
|
|
{/* Leg headers */}
|
|
<div
|
|
className="flex-1 grid"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${allLegOrders.length}, minmax(48px, 1fr))`,
|
|
}}
|
|
>
|
|
{allLegOrders.map((legOrder) => (
|
|
<div key={legOrder} className="flex flex-col items-center">
|
|
<span className="text-[10px] font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
Leg {legOrder}
|
|
</span>
|
|
<span className="text-[9px] text-gray-400 dark:text-gray-500 whitespace-nowrap truncate max-w-[48px]">
|
|
{legGameNames.get(legOrder)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Spacer matching status badge */}
|
|
<div className="shrink-0 w-[52px]" />
|
|
</div>
|
|
|
|
{/* Lineage cards */}
|
|
<div className="space-y-3">
|
|
{data.lineages.map((lineage) => (
|
|
<LineageCard
|
|
key={lineage.legs[0].encounterId}
|
|
lineage={lineage}
|
|
allLegOrders={allLegOrders}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|