Initial commit: Card & Value Explorer (React/Vite) with AE, MFL, SDG icon filtering, floating card layout, and focus overlay

This commit is contained in:
2026-05-12 06:12:47 +02:00
parent 45fed06ec9
commit 32fa8f5b4e
128 changed files with 8155 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
import { useRef, useMemo, useLayoutEffect, useState, useCallback } from 'react';
import type { CardData } from '../types';
import FloatingCard from './FloatingCard';
import ConnectionLines from './ConnectionLines';
import type { CardRect } from './ConnectionLines';
interface CardFieldProps {
cards: CardData[];
focusedId: number | null;
onFocus: (id: number | null) => void;
}
interface LayoutPos {
x: number;
y: number;
angle: number;
}
function computePositions(count: number, w: number, h: number): LayoutPos[] {
if (count === 0) return [];
const cardW = 150;
const cardH = 210;
const cx = w / 2 - cardW / 2;
const cy = h / 2 - cardH / 2;
if (count === 1) return [{ x: cx, y: cy, angle: 0 }];
const maxR = Math.min(w, h) * 0.35;
const minR = maxR * 0.3;
const positions: LayoutPos[] = [];
for (let i = 0; i < count; i++) {
const t = i / count;
const angle = t * Math.PI * 2 - Math.PI / 2;
const radius = minR + (maxR - minR) * (0.5 + 0.5 * Math.sin(t * Math.PI));
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
const rot = ((i * 137.5) % 12) - 6;
positions.push({ x, y, angle: rot });
}
return positions;
}
export default function CardField({ cards, focusedId, onFocus }: CardFieldProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cardRectsRef = useRef<CardRect[]>([]);
const [size, setSize] = useState({ w: 800, h: 500 });
const [rects, setRects] = useState<CardRect[]>([]);
useLayoutEffect(() => {
const el = fieldRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setSize({ w: width, h: height });
}
});
ro.observe(el);
const rect = el.getBoundingClientRect();
setSize({ w: rect.width, h: rect.height });
return () => ro.disconnect();
}, []);
const measure = useCallback(() => {
const el = fieldRef.current;
if (!el) return;
const fieldRect = el.getBoundingClientRect();
const items = el.querySelectorAll<HTMLDivElement>('[data-card-id]');
const measured: CardRect[] = [];
items.forEach((item) => {
const id = Number(item.dataset.cardId);
if (isNaN(id)) return;
const r = item.getBoundingClientRect();
measured.push({
id,
cx: r.left - fieldRect.left + r.width / 2,
cy: r.top - fieldRect.top + r.height / 2,
});
});
cardRectsRef.current = measured;
setRects(measured);
}, []);
useLayoutEffect(() => {
if (cards.length === 0) {
setRects([]);
return;
}
const raf = requestAnimationFrame(measure);
return () => cancelAnimationFrame(raf);
}, [cards.length, measure]);
useLayoutEffect(() => {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [measure]);
const positions = useMemo(
() => computePositions(cards.length, size.w, size.h),
[cards.length, size.w, size.h],
);
const hasFocus = focusedId !== null;
return (
<div className="card-field" ref={fieldRef}>
{cards.length === 0 && (
<div className="empty-cosmos">
<span>select icons to reveal cards</span>
</div>
)}
<ConnectionLines cards={cards} rects={rects} hasFocus={hasFocus} fieldW={size.w} fieldH={size.h} />
{cards.map((card, i) => {
const pos = positions[i] ?? { x: 0, y: 0, angle: 0 };
const isFocused = focusedId === card.id;
return (
<FloatingCard
key={card.id}
card={card}
focused={isFocused}
dimmed={hasFocus && !isFocused}
x={pos.x}
y={pos.y}
angle={pos.angle}
onFocus={onFocus}
cardRef={(el) => {
if (el) el.dataset.cardId = String(card.id);
}}
/>
);
})}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { motion } from 'framer-motion';
import { getCardImagePath } from '../data/cardImages';
import { getIconPath } from '../data/iconPaths';
import { AE_LABELS, MFL_LABELS, SDG_LABELS } from '../data/labels';
import type { CardData } from '../types';
function IconTags({ values, set, labels }: { values: number[]; set: string; labels: Record<number, string> }) {
return (
<div className="icon-tags">
{values.map((v) => (
<span key={v} className={`icon-tag ${set}`}>
<img src={getIconPath(set as 'ae' | 'mfl' | 'sdg', v)} alt="" className="icon-tag-img" />
{set.toUpperCase()} {v}: {labels[v]}
</span>
))}
</div>
);
}
interface CardFocusOverlayProps {
card: CardData;
onClose: () => void;
}
export default function CardFocusOverlay({ card, onClose }: CardFocusOverlayProps) {
const imgSrc = getCardImagePath(card.id);
return (
<motion.div
className="focus-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
>
<div className="focus-layout" onClick={(e) => e.stopPropagation()}>
<motion.div
className="focus-card"
initial={{ scale: 0.6, opacity: 0, x: -40 }}
animate={{ scale: 1, opacity: 1, x: 0 }}
transition={{ type: 'spring', stiffness: 200, damping: 25, mass: 1, delay: 0.05 }}
>
<img src={imgSrc} alt={card.name} className="focus-card-img" draggable={false} />
</motion.div>
<motion.div
className="focus-details"
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: 'spring', stiffness: 150, damping: 20, delay: 0.12 }}
>
<h2>{card.name}</h2>
<div className="detail-section">
<h3>Action</h3>
<p className="card-action">{card.action_text}</p>
</div>
<div className="detail-section">
<h3>Description</h3>
<p className="card-desc">{card.description}</p>
</div>
<div className="detail-section">
<h3>Associated Values</h3>
<div className="value-tags">
<IconTags values={card.ae} set="ae" labels={AE_LABELS} />
<IconTags values={card.mfl} set="mfl" labels={MFL_LABELS} />
<IconTags values={card.sdgs} set="sdg" labels={SDG_LABELS} />
</div>
</div>
</motion.div>
</div>
</motion.div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import type { CardData, ValueSet } from '../types';
import { VALUE_SET_COLORS } from '../types';
export interface CardRect {
id: number;
cx: number;
cy: number;
}
function getSharedSets(a: CardData, b: CardData): ValueSet[] {
const shared: ValueSet[] = [];
if (a.ae.some((v) => b.ae.includes(v))) shared.push('ae');
if (a.mfl.some((v) => b.mfl.includes(v))) shared.push('mfl');
if (a.sdgs.some((v) => b.sdgs.includes(v))) shared.push('sdg');
return shared;
}
interface ConnectionLine {
from: { x: number; y: number };
to: { x: number; y: number };
color: string;
}
interface ConnectionLinesProps {
cards: CardData[];
rects: CardRect[];
hasFocus: boolean;
fieldW: number;
fieldH: number;
}
export default function ConnectionLines({ cards, rects, hasFocus, fieldW, fieldH }: ConnectionLinesProps) {
if (cards.length < 2 || rects.length < 2) return null;
const lines: ConnectionLine[] = [];
for (let i = 0; i < cards.length; i++) {
for (let j = i + 1; j < cards.length; j++) {
const sets = getSharedSets(cards[i], cards[j]);
if (sets.length === 0) continue;
const ri = rects.find((r) => r.id === cards[i].id);
const rj = rects.find((r) => r.id === cards[j].id);
if (!ri || !rj) continue;
sets.forEach((set, idx) => {
const offset = (idx - (sets.length - 1) / 2) * 6;
const dx = rj.cx - ri.cx;
const dy = rj.cy - ri.cy;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1) return;
const nx = -dy / len;
const ny = dx / len;
lines.push({
from: { x: ri.cx + nx * offset, y: ri.cy + ny * offset },
to: { x: rj.cx + nx * offset, y: rj.cy + ny * offset },
color: VALUE_SET_COLORS[set],
});
});
}
}
return (
<svg className="connection-lines" width={fieldW} height={fieldH}>
{lines.map((line, i) => (
<line
key={i}
x1={line.from.x}
y1={line.from.y}
x2={line.to.x}
y2={line.to.y}
stroke={line.color}
strokeWidth={hasFocus ? 1 : 2}
opacity={hasFocus ? 0.08 : 0.25}
/>
))}
</svg>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { motion } from 'framer-motion';
import { getCardImagePath } from '../data/cardImages';
import type { CardData } from '../types';
interface FloatingCardProps {
card: CardData;
focused: boolean;
dimmed: boolean;
x: number;
y: number;
angle: number;
onFocus: (id: number | null) => void;
cardRef?: (el: HTMLDivElement | null) => void;
}
export default function FloatingCard({ card, focused, dimmed, x, y, angle, onFocus, cardRef }: FloatingCardProps) {
const imgSrc = getCardImagePath(card.id);
return (
<motion.div
ref={cardRef}
className={`floating-card ${focused ? 'focused' : ''}`}
initial={false}
animate={
focused
? {
left: '50%',
top: '50%',
x: '-50%',
y: '-50%',
scale: 1.3,
zIndex: 100,
opacity: 1,
rotate: 0,
}
: {
left: 0,
top: 0,
x,
y,
scale: 1,
zIndex: dimmed ? 0 : 1,
opacity: dimmed ? 0.1 : 1,
rotate: angle,
}
}
transition={{
type: 'spring',
stiffness: 200,
damping: 25,
mass: 1,
}}
whileHover={focused || dimmed ? undefined : { scale: 2, zIndex: 50 }}
onClick={() => onFocus(focused ? null : card.id)}
>
<div className="floating-card-inner">
<img src={imgSrc} alt={card.name} className="floating-card-img" draggable={false} />
</div>
</motion.div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ValueSet } from '../types';
import IconButton from './IconButton';
interface IconBarProps {
set: ValueSet;
count: number;
labels: Record<number, string>;
selected: Set<number>;
onToggle: (value: number) => void;
}
export default function IconBar({ set, count, labels, selected, onToggle }: IconBarProps) {
const values = Array.from({ length: count }, (_, i) => i + 1);
return (
<div className="icon-bar">
{values.map((v) => (
<IconButton
key={v}
set={set}
value={v}
label={labels[v] ?? ''}
selected={selected.has(v)}
onToggle={onToggle}
/>
))}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { motion } from 'framer-motion';
import { getIconPath } from '../data/iconPaths';
import type { ValueSet } from '../types';
interface IconButtonProps {
set: ValueSet;
value: number;
label: string;
selected: boolean;
onToggle: (value: number) => void;
}
export default function IconButton({ set, value, label, selected, onToggle }: IconButtonProps) {
const src = getIconPath(set, value);
return (
<motion.button
className={`icon-btn ${set} ${selected ? 'selected' : ''}`}
onClick={() => onToggle(value)}
whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.1 }}
animate={selected ? { scale: [1, 1.15, 1], transition: { duration: 0.3 } } : { scale: 1 }}
title={`${set.toUpperCase()}${value}: ${label}`}
>
<img src={src} alt={`${set.toUpperCase()}${value}`} className="icon-btn-img" draggable={false} />
</motion.button>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ValueSet } from '../types';
import IconButton from './IconButton';
interface IconColumnProps {
set: ValueSet;
count: number;
labels: Record<number, string>;
selected: Set<number>;
onToggle: (value: number) => void;
}
export default function IconColumn({ set, count, labels, selected, onToggle }: IconColumnProps) {
const values = Array.from({ length: count }, (_, i) => i + 1);
return (
<div className="icon-column">
{values.map((v) => (
<IconButton
key={v}
set={set}
value={v}
label={labels[v] ?? ''}
selected={selected.has(v)}
onToggle={onToggle}
/>
))}
</div>
);
}