diff --git a/src/blocks/MoneyFlow/MoneyFlowExplorer.tsx b/src/blocks/MoneyFlow/MoneyFlowExplorer.tsx index 3835062..947e1ea 100644 --- a/src/blocks/MoneyFlow/MoneyFlowExplorer.tsx +++ b/src/blocks/MoneyFlow/MoneyFlowExplorer.tsx @@ -1,7 +1,24 @@ 'use client' -import { useId, useState } from 'react' +import { useId, useMemo, useState } from 'react' import { usePathname } from 'next/navigation' +import { + ArrowRight, + ArrowUpRight, + Banknote, + Bitcoin, + CircleHelp, + Globe2, + GraduationCap, + Landmark, + MapPin, + RefreshCw, + Send, + ShoppingBasket, + Smartphone, + Wallet as WalletIcon, +} from 'lucide-react' + import { goalChoices, journeyCopy, @@ -14,107 +31,224 @@ import { type Origin, type Wallet, } from './journeyData' +import { buildNetworkEdges, GRAPH, networkNodes, type NetworkNode } from './networkLayout' + +type Props = { title?: string; description?: string } +type Language = 'fr' | 'en' -type Props = { - title?: string - description?: string +const palette: Record = { + documented: { + stroke: '#087b58', + pill: 'border-emerald-500/50 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', + dot: 'bg-emerald-600', + }, + conditional: { + stroke: '#bb812c', + pill: 'border-amber-500/50 bg-amber-500/10 text-amber-900 dark:text-amber-200', + dot: 'bg-amber-500', + }, + unknown: { + stroke: '#969ead', + pill: 'border-border bg-muted text-foreground', + dot: 'bg-slate-400', + }, } -const levels: Record = { - documented: 'border-emerald-600/40 bg-emerald-700/10 text-emerald-800 dark:text-emerald-300', - conditional: 'border-amber-600/40 bg-amber-500/10 text-amber-900 dark:text-amber-300', - unknown: 'border-border bg-muted text-foreground', +const nodeIcons = { + france: Globe2, + cameroon: MapPin, + orange: WalletIcon, + mtn: Smartphone, + bank: Landmark, + cash: Banknote, + school: GraduationCap, + shopping: ShoppingBasket, + international: Send, + crypto: Bitcoin, } -function StatusPill({ value, text }: { value: EvidenceStatus; text: string }) { +const subtitles: Record> = { + fr: { + france: 'Diaspora', cameroon: 'Circuit national', orange: 'Portefeuille mobile', + mtn: 'Portefeuille mobile', bank: 'Compte bancaire', cash: 'Retrait ou dépôt', + school: 'Établissement éligible', shopping: 'Commerçant partenaire', + international: 'Couloir transfrontalier', crypto: 'Statut à vérifier', + }, + en: { + france: 'Diaspora transfer', cameroon: 'Domestic route', orange: 'Mobile wallet', + mtn: 'Mobile wallet', bank: 'Bank account', cash: 'Cash pickup / deposit', + school: 'Eligible school', shopping: 'Participating merchant', + international: 'Cross-border route', crypto: 'Regulatory review', + }, +} + +const scenarios: { id: string; origin: Origin; wallet: Wallet; goal: Goal; fr: string; en: string }[] = [ + { id: 'fees', origin: 'france', wallet: 'orange', goal: 'school', fr: 'Recevoir et payer l’école', en: 'Receive and pay school fees' }, + { id: 'shops', origin: 'cameroon', wallet: 'mtn', goal: 'shopping', fr: 'Payer ses achats', en: 'Pay at a shop' }, + { id: 'cross-border', origin: 'cameroon', wallet: 'bank', goal: 'international', fr: 'Envoyer à l’étranger', en: 'Send money abroad' }, +] + +function StatusPill({ status, locale }: { status: EvidenceStatus; locale: Language }) { return ( - - {text} + + ) } -type ChoiceColumn = { - legend: string - values: readonly T[] - selected: T - labels: Record - onSelect: (value: T) => void - labelId: string +function nodeLabel(node: NetworkNode, locale: Language): string { + const copy = journeyCopy[locale] + if (node.column === 'origin') return copy.origins[node.key] + if (node.column === 'wallet') return copy.wallets[node.key] + return copy.goals[node.key] } -function ChoiceColumn({ - legend, values, selected, labels, onSelect, labelId, -}: ChoiceColumn) { +function NetworkCanvas({ + origin, wallet, goal, setOrigin, setWallet, setGoal, locale, labelId, +}: { + origin: Origin + wallet: Wallet + goal: Goal + setOrigin: (next: Origin) => void + setWallet: (next: Wallet) => void + setGoal: (next: Goal) => void + locale: Language + labelId: string +}) { + const edges = useMemo(() => buildNetworkEdges({ origin, wallet, goal }), [origin, wallet, goal]) + const selectedJourney = resolveJourney(origin, wallet, goal) + const cols = journeyCopy[locale].stages + const order = [...edges.filter((edge) => !edge.selected), ...edges.filter((edge) => edge.selected)] + return ( -
- - {legend} - - {values.map((value) => { - const chosen = selected === value +
+
- ) -} - -function FlowLinks({ originIndex, walletIndex, goalIndex, unknown }: { - originIndex: number - walletIndex: number - goalIndex: number - unknown: boolean -}) { - const y = (index: number) => 84 + index * 70 - const first = `M 314 ${y(originIndex)} C 326 ${y(originIndex)}, 340 ${y(walletIndex)}, 350 ${y(walletIndex)}` - const second = `M 648 ${y(walletIndex)} C 660 ${y(walletIndex)}, 674 ${y(goalIndex)}, 685 ${y(goalIndex)}` - return ( - + + ) } /** - * A decision-flow explorer, not a Sankey: line thickness does NOT imply volume. - * Accessible buttons own interaction; SVG connectors are decorative. + * An explorable network, NOT a Sankey: edge thickness never means money volume. + * Real SVG edges span the full distance between nodes. Buttons remain native + * keyboard/touch targets; the path graphic is decorative, text explains evidence. */ export function MoneyFlowExplorer({ title, description }: Props) { const pathname = usePathname() - const locale = pathname.startsWith('/en/') || pathname === '/en' ? 'en' : 'fr' + const locale: Language = pathname.startsWith('/en/') || pathname === '/en' ? 'en' : 'fr' const copy = journeyCopy[locale] const id = useId() const [origin, setOrigin] = useState('france') @@ -122,6 +256,7 @@ export function MoneyFlowExplorer({ title, description }: Props) { const [goal, setGoal] = useState('school') const result = resolveJourney(origin, wallet, goal) const sources = [...new Set([...result.incoming.sources, ...result.outgoing.sources])] + const selectedLabels = [copy.origins[origin], copy.wallets[wallet], copy.goals[goal]] const reset = () => { setOrigin('france') @@ -131,109 +266,146 @@ export function MoneyFlowExplorer({ title, description }: Props) { return (
-
-

- Trigenys Insight · Explorer +

+

+ Trigenys Insight / Data Stories

-
+
-

+

{title || (locale === 'fr' ? 'Où peut aller mon argent ?' : 'Where can my money go?')}

-

+

{description || copy.intro}

+
+ {scenarios.map((scenario) => { + const active = origin === scenario.origin && wallet === scenario.wallet && goal === scenario.goal + return ( + + ) + })} +
-
- -
- - - - - +
+
+
+ {(['documented', 'conditional', 'unknown'] as const).map((status) => ( + + + ))} +
+ +
-
-
-
-

{copy.title}

- +

+ {locale === 'fr' + ? 'Graphe de parcours : choisissez un pays, un moyen de réception, puis un usage. Les courbes relient les options disponibles à explorer, sans garantir les transactions.' + : 'Journey network: choose a country, a payment receipt method and a goal. Curved links are options to investigate, not guaranteed payments.'} +

+ +
+
-
    - {[ - { title: copy.step1, stage: result.incoming }, - { title: copy.step2, stage: result.outgoing }, - ].map(({ title: stepTitle, stage }) => ( -
  1. -
    - {stepTitle} - -
    -

    {stage.note[locale]}

    -
  2. - ))} -
- {sources.length > 0 && ( -
-
{copy.sources}
- +

+ {locale === 'fr' ? 'Sur téléphone : faites glisser le graphe horizontalement pour explorer tous les nœuds.' : 'On mobile: swipe horizontally to explore every node.'} +

+ +
+
+
+

+ {locale === 'fr' ? 'Parcours sélectionné' : 'Selected journey'} +

+

{copy.title}

+
+
- )} + +
+ {selectedLabels.map((label, index) => ( + + {index > 0 && + ))} +
+ +
    + {[ + { stage: result.incoming, title: copy.step1 }, + { stage: result.outgoing, title: copy.step2 }, + ].map(({ stage, title: stageTitle }) => ( +
  1. +
    + {stageTitle} + +
    +

    {stage.note[locale]}

    +
  2. + ))} +
+ + {sources.length > 0 && ( +
+

{copy.sources}

+ +
+ )} +
+

{copy.footnote}

-

{copy.footnote}

) } diff --git a/src/blocks/MoneyFlow/networkLayout.ts b/src/blocks/MoneyFlow/networkLayout.ts new file mode 100644 index 0000000..f52da0d --- /dev/null +++ b/src/blocks/MoneyFlow/networkLayout.ts @@ -0,0 +1,92 @@ +import { + goalChoices, + originChoices, + resolveJourney, + walletChoices, + type EvidenceStatus, + type Goal, + type Origin, + type Wallet, +} from './journeyData' + +/** Logical coordinates used by both SVG paths and clickable HTML nodes. */ +export const GRAPH = { width: 1120, height: 515, nodeWidth: 220, nodeHeight: 76 } as const + +export type NetworkNode = + | { column: 'origin'; key: Origin; x: number; y: number } + | { column: 'wallet'; key: Wallet; x: number; y: number } + | { column: 'goal'; key: Goal; x: number; y: number } + +export type NetworkEdge = { + key: string + path: string + kind: 'incoming' | 'outgoing' + status: EvidenceStatus + selected: boolean +} + +const originY = [156, 320] +const otherY = [72, 170, 268, 366] + +export const networkNodes: NetworkNode[] = [ + ...originChoices.map((key, i): NetworkNode => ({ column: 'origin', key, x: 46, y: originY[i] })), + ...walletChoices.map((key, i): NetworkNode => ({ column: 'wallet', key, x: 452, y: otherY[i] })), + ...goalChoices.map((key, i): NetworkNode => ({ column: 'goal', key, x: 854, y: otherY[i] })), +] + +export function connectNodes( + source: Pick, + target: Pick, +): string { + const fromX = source.x + GRAPH.nodeWidth + const toX = target.x + const fromY = source.y + GRAPH.nodeHeight / 2 + const toY = target.y + GRAPH.nodeHeight / 2 + const bend = (toX - fromX) * 0.55 + return [ + 'M', fromX, fromY, + 'C', fromX + bend, fromY, + toX - bend, toY, + toX, toY, + ].join(' ') +} + +/** Every visible arc is a source-backed or explicitly conditional candidate route. */ +export function buildNetworkEdges(selected: { + origin: Origin + wallet: Wallet + goal: Goal +}): NetworkEdge[] { + const edges: NetworkEdge[] = [] + for (const origin of originChoices) { + for (const wallet of walletChoices) { + const from = networkNodes.find((node) => node.column === 'origin' && node.key === origin)! + const to = networkNodes.find((node) => node.column === 'wallet' && node.key === wallet)! + edges.push({ + key: 'in:' + origin + ':' + wallet, + path: connectNodes(from, to), + kind: 'incoming', + status: resolveJourney(origin, wallet, 'shopping').incoming.status, + selected: selected.origin === origin && selected.wallet === wallet, + }) + } + } + for (const wallet of walletChoices) { + for (const goal of goalChoices) { + const from = networkNodes.find((node) => node.column === 'wallet' && node.key === wallet)! + const to = networkNodes.find((node) => node.column === 'goal' && node.key === goal)! + edges.push({ + key: 'out:' + wallet + ':' + goal, + path: connectNodes(from, to), + kind: 'outgoing', + status: resolveJourney('cameroon', wallet, goal).outgoing.status, + selected: selected.wallet === wallet && selected.goal === goal, + }) + } + } + return edges +} + +export function nodeCenter(node: NetworkNode): { x: number; y: number } { + return { x: node.x + GRAPH.nodeWidth / 2, y: node.y + GRAPH.nodeHeight / 2 } +} diff --git a/tests/unit/moneyFlowNetwork.spec.ts b/tests/unit/moneyFlowNetwork.spec.ts new file mode 100644 index 0000000..54a09ff --- /dev/null +++ b/tests/unit/moneyFlowNetwork.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { + buildNetworkEdges, + connectNodes, + GRAPH, + networkNodes, + nodeCenter, +} from '@/blocks/MoneyFlow/networkLayout' +import { + goalChoices, + originChoices, + resolveJourney, + walletChoices, +} from '@/blocks/MoneyFlow/journeyData' + +describe('money-flow interactive network topology', () => { + it('draws every candidate edge in a real network, not two disconnected stubs', () => { + const edges = buildNetworkEdges({ origin: 'france', wallet: 'orange', goal: 'school' }) + expect(networkNodes).toHaveLength(10) + expect(edges).toHaveLength(24) + expect(edges.filter((edge) => edge.kind === 'incoming')).toHaveLength(8) + expect(edges.filter((edge) => edge.kind === 'outgoing')).toHaveLength(16) + expect(edges.filter((edge) => edge.selected).map((edge) => edge.key)).toEqual([ + 'in:france:orange', + 'out:orange:school', + ]) + expect(new Set(edges.map((edge) => edge.key)).size).toBe(24) + }) + + it('anchors every link on the actual edge of its nodes, with room to see the curve', () => { + const source = networkNodes.find((node) => node.column === 'origin' && node.key === 'france')! + const target = networkNodes.find((node) => node.column === 'wallet' && node.key === 'orange')! + const path = connectNodes(source, target) + const fromY = source.y + GRAPH.nodeHeight / 2 + const toY = target.y + GRAPH.nodeHeight / 2 + expect(path.startsWith('M ' + (source.x + GRAPH.nodeWidth) + ' ' + fromY)).toBe(true) + expect(path.endsWith(target.x + ' ' + toY)).toBe(true) + expect(target.x - source.x - GRAPH.nodeWidth).toBeGreaterThan(150) + }) + + it('stays within the 1120 x 515 drawing surface', () => { + for (const node of networkNodes) { + expect(node.x).toBeGreaterThanOrEqual(0) + expect(node.y).toBeGreaterThanOrEqual(0) + expect(node.x + GRAPH.nodeWidth).toBeLessThanOrEqual(GRAPH.width) + expect(node.y + GRAPH.nodeHeight).toBeLessThanOrEqual(GRAPH.height) + expect(nodeCenter(node).x).toBeGreaterThan(node.x) + } + }) + + it('changes exactly two highlighted edges for each of the 32 possible selections', () => { + for (const origin of originChoices) { + for (const wallet of walletChoices) { + for (const goal of goalChoices) { + const edges = buildNetworkEdges({ origin, wallet, goal }) + const active = edges.filter((edge) => edge.selected) + const actual = resolveJourney(origin, wallet, goal) + expect(active).toHaveLength(2) + expect(active[0].status).toBe(actual.incoming.status) + expect(active[1].status).toBe(actual.outgoing.status) + expect(active.map((edge) => edge.key)).toEqual([ + 'in:' + origin + ':' + wallet, 'out:' + wallet + ':' + goal, + ]) + } + } + } + }) + + it('keeps crypto paths unknown rather than presenting them as confirmed payment rails', () => { + for (const wallet of walletChoices) { + const edge = buildNetworkEdges({ origin: 'france', wallet, goal: 'crypto' }) + .find((item) => item.key === 'out:' + wallet + ':crypto') + expect(edge?.status).toBe('unknown') + } + }) +})