Initial commit: Card & Value Explorer (React/Vite) with AE, MFL, SDG icon filtering, floating card layout, and focus overlay
This commit is contained in:
+87
@@ -0,0 +1,87 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import type { CardData } from './types';
|
||||
import cardDataRaw from '../card_data.json?raw';
|
||||
import { AE_LABELS, MFL_LABELS, SDG_LABELS } from './data/labels';
|
||||
import IconBar from './components/IconBar';
|
||||
import IconColumn from './components/IconColumn';
|
||||
import CardField from './components/CardField';
|
||||
import CardFocusOverlay from './components/CardFocusOverlay';
|
||||
import './styles/app.css';
|
||||
|
||||
const cards: CardData[] = JSON.parse(cardDataRaw);
|
||||
|
||||
const AE_COUNT = 13;
|
||||
const MFL_COUNT = 12;
|
||||
const SDG_COUNT = 17;
|
||||
|
||||
function matchesAll(card: CardData, selectedAE: Set<number>, selectedMFL: Set<number>, selectedSDG: Set<number>): boolean {
|
||||
for (const v of selectedAE) if (!card.ae.includes(v)) return false;
|
||||
for (const v of selectedMFL) if (!card.mfl.includes(v)) return false;
|
||||
for (const v of selectedSDG) if (!card.sdgs.includes(v)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [selectedAE, setSelectedAE] = useState<Set<number>>(new Set());
|
||||
const [selectedMFL, setSelectedMFL] = useState<Set<number>>(new Set());
|
||||
const [selectedSDG, setSelectedSDG] = useState<Set<number>>(new Set());
|
||||
const [focusedCardId, setFocusedCardId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFocusedCardId(null);
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
const toggleSet = useCallback((value: number, set: Set<number>, setter: (s: Set<number>) => void) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
setter(next);
|
||||
setFocusedCardId(null);
|
||||
}, []);
|
||||
|
||||
const filteredCards = useMemo(() => {
|
||||
const total = selectedAE.size + selectedMFL.size + selectedSDG.size;
|
||||
if (total === 0) return [];
|
||||
return cards.filter((c) => matchesAll(c, selectedAE, selectedMFL, selectedSDG));
|
||||
}, [selectedAE, selectedMFL, selectedSDG]);
|
||||
|
||||
const active = { ae: selectedAE.size > 0, mfl: selectedMFL.size > 0, sdg: selectedSDG.size > 0 };
|
||||
|
||||
const handleFocus = useCallback((id: number | null) => {
|
||||
setFocusedCardId(id);
|
||||
}, []);
|
||||
|
||||
const focusedCard = focusedCardId !== null ? cards.find((c) => c.id === focusedCardId) ?? null : null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{focusedCard && <CardFocusOverlay card={focusedCard} onClose={() => setFocusedCardId(null)} />}
|
||||
|
||||
<div className="ae-banner">
|
||||
<IconBar set="ae" count={AE_COUNT} labels={AE_LABELS} selected={selectedAE} onToggle={(v) => toggleSet(v, selectedAE, setSelectedAE)} />
|
||||
</div>
|
||||
|
||||
<div className="sdg-banner">
|
||||
<IconBar set="sdg" count={SDG_COUNT} labels={SDG_LABELS} selected={selectedSDG} onToggle={(v) => toggleSet(v, selectedSDG, setSelectedSDG)} />
|
||||
</div>
|
||||
|
||||
<div className="mfl-sidebar">
|
||||
<IconColumn set="mfl" count={MFL_COUNT} labels={MFL_LABELS} selected={selectedMFL} onToggle={(v) => toggleSet(v, selectedMFL, setSelectedMFL)} />
|
||||
</div>
|
||||
|
||||
<div className="center-field">
|
||||
<div className="field-label-top">
|
||||
<span className="label-ae">{active.ae ? 'AE' : ''}</span>
|
||||
<span className="label-mfl">{active.mfl ? 'MFL' : ''}</span>
|
||||
<span className="label-sdg">{active.sdg ? 'SDG' : ''}</span>
|
||||
<span className="label-count">{filteredCards.length} cards</span>
|
||||
</div>
|
||||
<CardField cards={filteredCards} focusedId={focusedCardId} onFocus={handleFocus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const CARD_IMG: (string | null)[] = [
|
||||
null,
|
||||
"1-Gift-of-Water-Action.png","2-Grow-Greens-Action.png","3-Fine-Dining-Compost-Dish-Action.png",
|
||||
"4-Buzzing-with-Wisdom-Action.png","5-Baking-Flatbread.png","6-Mixed-Wastemonster-Action.png",
|
||||
"7-Ingredient-Detective-Action.png","8-Mindful-Eating-Action.png","9-Fermentastic-Action.png",
|
||||
"10 - Tree Planter.png","11 - Seed Scout.png","12 - Natural Spritz.png",
|
||||
"13 - Dashboard Designer.png","14 - Onion Bodyguard.png","15 - Local Investor.png",
|
||||
"16 - Poster Designer.png","17 - Change-Maker.png","18 - Waste Remover.png",
|
||||
"19 - Manure Maker.png","20 - Mixed Garden.png","21 - Ash Bug Blaster.png",
|
||||
"22 - Dry & Store.png","23 - Seed Distributor.png","24 - River Cleaners.png",
|
||||
"25 - Garden Documenter.png","26 - Local Variety Guardian.png","27 - Nature Regenerator.png",
|
||||
"28 - Biodiversity Steward.png","29 - Land Negotiator.png","30-TJD-Booster-Action.png",
|
||||
"31 - Kitchen Garden.png","32 - Balanced Plate Builder.png","33 - Indigenous Tree Lover.png",
|
||||
"34 - Cover Crop Protector.png","35 - Local Food Producer.png","36 - Market Price Checker.png",
|
||||
"37 - Community Meeting.png","38 - Jar Farm Product Marketer.png","39 - Food Donation Hero.png",
|
||||
"40 - Windbreak Warrior.png","41 - Seed Champion.png","42 - Fruit & Tree Team.png",
|
||||
"43 - Manure Cycle Drawer.png","44 - Animal Hero.png","45 - Land Magic.png",
|
||||
"46 - Local Chicken Lover.png","47 - Food Dryer Explorer.png","48 - Scrap-tastic Animal Feeder.png",
|
||||
"49 - Leftover Chef.png","50 - Weather Tracker Checker.png","51 - Farm Record Keeper.png",
|
||||
"52 - The Water Fertilizers.png","53 - Small Farm, Big Voice.png","54 - Nature Educator.png",
|
||||
"55 - Teamwork Harvest.png","56 - Sky Garden.png","57-Be-the-Change-Action.png",
|
||||
"58-Ancient-Wisdom-Defender-Action.png","59-Seedling-Protector-Action.png","60-Cooperative-Builder-Action.png",
|
||||
];
|
||||
|
||||
export function getCardImagePath(cardId: number): string {
|
||||
const filename = CARD_IMG[cardId];
|
||||
if (!filename) return '';
|
||||
return `/cards/${encodeURI(filename)}`;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ValueSet } from '../types';
|
||||
|
||||
const SET_DIR: Record<ValueSet, string> = {
|
||||
ae: 'AE SVG',
|
||||
mfl: 'MFL',
|
||||
sdg: 'SDG',
|
||||
};
|
||||
|
||||
const SET_EXT: Record<ValueSet, string> = {
|
||||
ae: 'svg',
|
||||
mfl: 'png',
|
||||
sdg: 'png',
|
||||
};
|
||||
|
||||
export function getIconPath(set: ValueSet, n: number): string {
|
||||
if (set === 'sdg' && n === 10) return `/icons/SDG/sdg-10.png`;
|
||||
const dir = SET_DIR[set];
|
||||
const ext = SET_EXT[set];
|
||||
return `/icons/${dir}/${set.toUpperCase()}-${n}.${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export const AE_LABELS: Record<number, string> = {
|
||||
1: 'Recycling',
|
||||
2: 'Input reduction',
|
||||
3: 'Soil health',
|
||||
4: 'Animal health',
|
||||
5: 'Biodiversity',
|
||||
6: 'Synergy',
|
||||
7: 'Economic diversification',
|
||||
8: 'Co-creation of knowledge',
|
||||
9: 'Social values & diets',
|
||||
10: 'Fairness',
|
||||
11: 'Connectivity',
|
||||
12: 'Land & natural governance',
|
||||
13: 'Participation',
|
||||
};
|
||||
|
||||
export const MFL_LABELS: Record<number, string> = {
|
||||
1: 'Conservation',
|
||||
2: 'Production',
|
||||
3: 'Consumption',
|
||||
4: 'Trees',
|
||||
5: 'Water',
|
||||
6: 'Seeds',
|
||||
7: 'Ecosystems',
|
||||
8: 'Markets',
|
||||
9: 'Waste',
|
||||
10: 'Data',
|
||||
11: 'Governance',
|
||||
12: 'Education',
|
||||
};
|
||||
|
||||
export const SDG_LABELS: Record<number, string> = {
|
||||
1: 'No poverty',
|
||||
2: 'Zero hunger',
|
||||
3: 'Good health',
|
||||
4: 'Quality education',
|
||||
5: 'Gender equality',
|
||||
6: 'Clean water',
|
||||
7: 'Clean energy',
|
||||
8: 'Decent work',
|
||||
9: 'Industry & innovation',
|
||||
10: 'Reduced inequalities',
|
||||
11: 'Sustainable cities',
|
||||
12: 'Responsible consumption',
|
||||
13: 'Climate action',
|
||||
14: 'Life below water',
|
||||
15: 'Life on land',
|
||||
16: 'Peace & justice',
|
||||
17: 'Partnerships',
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,373 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #070d07;
|
||||
--surface: #0f1a0f;
|
||||
--surface2: #162416;
|
||||
--accent-ae: #4CAF50;
|
||||
--accent-mfl: #009688;
|
||||
--accent-sdg: #FFC107;
|
||||
--text: #d0e8d0;
|
||||
--text-dim: #6a8f6a;
|
||||
--card-w: 150px;
|
||||
--card-h: 210px;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"ae mfl"
|
||||
"mid mfl"
|
||||
"sdg mfl";
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Icon bars ── */
|
||||
|
||||
.ae-banner {
|
||||
grid-area: ae;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--surface2);
|
||||
padding: 6px 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sdg-banner {
|
||||
grid-area: sdg;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--surface2);
|
||||
padding: 6px 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Icon buttons ── */
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn-img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon-btn.ae.selected {
|
||||
border-color: var(--accent-ae);
|
||||
box-shadow: 0 0 12px rgba(76, 175, 80, 0.5);
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
.icon-btn.mfl.selected {
|
||||
border-color: var(--accent-mfl);
|
||||
box-shadow: 0 0 12px rgba(0, 150, 136, 0.5);
|
||||
background: rgba(0, 150, 136, 0.1);
|
||||
}
|
||||
|
||||
.icon-btn.sdg.selected {
|
||||
border-color: var(--accent-sdg);
|
||||
box-shadow: 0 0 12px rgba(255, 193, 7, 0.5);
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
/* ── MFL sidebar ── */
|
||||
|
||||
.mfl-sidebar {
|
||||
grid-area: mfl;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--surface2);
|
||||
padding: 8px 6px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Center field ── */
|
||||
|
||||
.center-field {
|
||||
grid-area: mid;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(ellipse at center, #0f1f0f 0%, var(--bg) 70%);
|
||||
}
|
||||
|
||||
.field-label-top {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.field-label-top .label-ae { color: var(--accent-ae); }
|
||||
.field-label-top .label-mfl { color: var(--accent-mfl); }
|
||||
.field-label-top .label-sdg { color: var(--accent-sdg); }
|
||||
.field-label-top .label-count { color: var(--text); }
|
||||
|
||||
/* ── Card field ── */
|
||||
|
||||
.card-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-cosmos {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Floating cards ── */
|
||||
|
||||
.floating-card {
|
||||
position: absolute;
|
||||
width: var(--card-w);
|
||||
cursor: pointer;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.floating-card.focused {
|
||||
position: fixed;
|
||||
width: var(--card-w);
|
||||
}
|
||||
|
||||
.floating-card-inner {
|
||||
width: 100%;
|
||||
aspect-ratio: 150 / 210;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.floating-card.focused .floating-card-inner {
|
||||
box-shadow: 0 0 50px rgba(76, 175, 80, 0.35), 0 0 100px rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
.floating-card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Card focus overlay ── */
|
||||
|
||||
.focus-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.focus-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.focus-card {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.focus-card-img {
|
||||
width: 300px;
|
||||
height: 420px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 60px rgba(76, 175, 80, 0.3), 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.focus-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 480px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 26, 15, 0.92);
|
||||
border: 1px solid var(--surface2);
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.focus-details h2 {
|
||||
font-size: 1.6rem;
|
||||
color: var(--accent-ae);
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--surface2);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-section h3 {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.focus-details .card-action {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.focus-details .card-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Icon tags in details ── */
|
||||
|
||||
.value-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.icon-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px 4px 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.icon-tag-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.icon-tag.ae { border-color: rgba(76, 175, 80, 0.3); color: var(--accent-ae); }
|
||||
.icon-tag.mfl { border-color: rgba(0, 150, 136, 0.3); color: var(--accent-mfl); }
|
||||
.icon-tag.sdg { border-color: rgba(255, 193, 7, 0.3); color: var(--accent-sdg); }
|
||||
|
||||
/* ── Connection lines ── */
|
||||
|
||||
.connection-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ── */
|
||||
|
||||
.ae-banner::-webkit-scrollbar,
|
||||
.sdg-banner::-webkit-scrollbar,
|
||||
.mfl-sidebar::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.ae-banner::-webkit-scrollbar-track,
|
||||
.sdg-banner::-webkit-scrollbar-track,
|
||||
.mfl-sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ae-banner::-webkit-scrollbar-thumb,
|
||||
.sdg-banner::-webkit-scrollbar-thumb,
|
||||
.mfl-sidebar::-webkit-scrollbar-thumb {
|
||||
background: var(--surface2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.focus-details::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.focus-details::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.focus-details::-webkit-scrollbar-thumb {
|
||||
background: var(--surface2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface CardData {
|
||||
id: number;
|
||||
name: string;
|
||||
action_text: string;
|
||||
description: string;
|
||||
ae: number[];
|
||||
mfl: number[];
|
||||
sdgs: number[];
|
||||
}
|
||||
|
||||
export type ValueSet = 'ae' | 'mfl' | 'sdg';
|
||||
|
||||
export interface ConnectionLine {
|
||||
from: { x: number; y: number };
|
||||
to: { x: number; y: number };
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const VALUE_SET_COLORS: Record<ValueSet, string> = {
|
||||
ae: '#4CAF50',
|
||||
mfl: '#009688',
|
||||
sdg: '#FFC107',
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user