Compare commits
32 Commits
94474fc007
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b9bcfa8518 | |||
| 42286a8c0d | |||
| ee5e401185 | |||
| f29625c6bc | |||
| a2fdad46d4 | |||
| 45080d83ac | |||
| bea7cbe60f | |||
| bc61527b4e | |||
| ac2f5bc431 | |||
| 2debc3587a | |||
| 8a31b60716 | |||
| a9bf445747 | |||
| d7fef466f3 | |||
| 14d218e4ff | |||
| d50b30666b | |||
| 30057a07fb | |||
| 40c09e2e4b | |||
| 0aea929b48 | |||
| d4cc4fbd3a | |||
| bf2dbd6d35 | |||
| 2fce063703 | |||
| 3aa3933b4c | |||
| 2ed51243d2 | |||
| 7975abc619 | |||
| 26e429c8c0 | |||
| 03f63aec46 | |||
| ec06c4e35c | |||
| aa1f3d5f2f | |||
| 034e16ee37 | |||
| ead63f9459 | |||
| 2499fac213 | |||
| debb4cf8ec |
@@ -1,3 +1,8 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/.reports/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -22,3 +27,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/docs-plan/
|
||||
/docs-syoul/
|
||||
/docs-bugs/
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
when:
|
||||
- branch:
|
||||
- main
|
||||
- dev
|
||||
- ci
|
||||
event: push
|
||||
|
||||
steps:
|
||||
|
||||
# Etape 1 : Validation syntaxique du docker-compose.yml
|
||||
- name: validate
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
APP_DOMAIN: validate.example.com
|
||||
commands:
|
||||
- |
|
||||
export COMPOSE_PROJECT_NAME=$(printf '%s-%s-%s' "$CI_REPO_OWNER" "$CI_REPO_NAME" "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
|
||||
docker compose config --quiet
|
||||
- echo "docker-compose.yml valide"
|
||||
|
||||
# Etape 2 : Verifications de securite
|
||||
- name: security-check
|
||||
image: alpine:3.20
|
||||
commands:
|
||||
- |
|
||||
if [ -f .env ]; then
|
||||
echo "ERREUR: .env ne doit pas etre commite dans le depot"
|
||||
exit 1
|
||||
fi
|
||||
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
|
||||
- echo "Verifications de securite OK"
|
||||
|
||||
# Etape 3 : Build de l'image Docker en local sur sonic
|
||||
# VITE_USE_LIVE_API=true est bake dans l'image au moment du build (Vite)
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
- name: build-image
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- docker build -t g1flux:latest .
|
||||
- echo "Image g1flux:latest construite"
|
||||
|
||||
# Etape 4a : Generation SBOM (Syft) depuis l'image locale
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
- name: sbom-generate
|
||||
image: alpine:3.20
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||
- mkdir -p .reports
|
||||
- syft g1flux:latest -o cyclonedx-json --file .reports/sbom-app.cyclonedx.json
|
||||
- echo "SBOM genere"
|
||||
|
||||
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
|
||||
- name: sbom-scan
|
||||
image: aquasec/trivy:latest
|
||||
volumes:
|
||||
- /home/syoul/trivy-cache:/root/.cache/trivy
|
||||
commands:
|
||||
- trivy sbom --format json --output .reports/trivy-app.json .reports/sbom-app.cyclonedx.json
|
||||
- echo "Scan CVE termine"
|
||||
|
||||
# Etape 4c : Publication SBOM vers Dependency-Track
|
||||
# NOTE: from_secret + pas de volumes : compatible
|
||||
- name: sbom-publish
|
||||
image: alpine/curl:latest
|
||||
environment:
|
||||
DTRACK_TOKEN:
|
||||
from_secret: dependency_track_token
|
||||
DTRACK_DOMAIN:
|
||||
from_secret: dtrack_domain
|
||||
commands:
|
||||
- |
|
||||
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
|
||||
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
|
||||
-H "X-Api-Key: $DTRACK_TOKEN" \
|
||||
-F "autoCreate=true" \
|
||||
-F "projectName=g1flux-app" \
|
||||
-F "projectVersion=$VERSION" \
|
||||
-F "bom=@.reports/sbom-app.cyclonedx.json")
|
||||
echo "HTTP $HTTP : $(cat /tmp/dtrack-resp.txt)"
|
||||
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
|
||||
|
||||
# Etape 5a : Ecriture du .env.deploy depuis les secrets
|
||||
# NOTE: from_secret + pas de volumes : compatible
|
||||
- name: write-env
|
||||
image: alpine:3.20
|
||||
environment:
|
||||
APP_DOMAIN_BASE:
|
||||
from_secret: app_domain
|
||||
commands:
|
||||
- |
|
||||
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
EFFECTIVE_DOMAIN="$APP_DOMAIN_BASE"
|
||||
else
|
||||
EFFECTIVE_DOMAIN="$BRANCH.$APP_DOMAIN_BASE"
|
||||
fi
|
||||
OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z')
|
||||
REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z')
|
||||
echo "APP_DOMAIN=$EFFECTIVE_DOMAIN" > .env.deploy
|
||||
echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy
|
||||
- echo "Fichier .env.deploy cree ($(wc -c < .env.deploy) octets)"
|
||||
|
||||
# Etape 5b : Validation du .env.deploy
|
||||
- name: test-env
|
||||
image: alpine:3.20
|
||||
commands:
|
||||
- |
|
||||
[ -f .env.deploy ] || { echo "FAIL: .env.deploy introuvable"; exit 1; }
|
||||
echo "PASS: .env.deploy present"
|
||||
- |
|
||||
VAL=$(grep '^COMPOSE_PROJECT_NAME=' .env.deploy | cut -d= -f2)
|
||||
[ -z "$VAL" ] && echo "FAIL: COMPOSE_PROJECT_NAME vide" && exit 1
|
||||
echo "PASS: COMPOSE_PROJECT_NAME = $VAL"
|
||||
- |
|
||||
VAL=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
|
||||
[ -z "$VAL" ] && echo "FAIL: APP_DOMAIN vide" && exit 1
|
||||
echo "PASS: APP_DOMAIN = $VAL"
|
||||
|
||||
# Etape 6 : Deploiement sur sonic via Docker socket
|
||||
# Fabio routing gere automatiquement par Registrator via les labels SERVICE_* du compose
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
- name: deploy
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/g1flux:/opt/g1flux
|
||||
commands:
|
||||
- |
|
||||
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
|
||||
DEPLOY_DIR="/opt/g1flux/$BRANCH"
|
||||
mkdir -p "$DEPLOY_DIR"
|
||||
cp .env.deploy "$DEPLOY_DIR/.env"
|
||||
chmod 600 "$DEPLOY_DIR/.env"
|
||||
cp docker-compose.yml "$DEPLOY_DIR/docker-compose.yml"
|
||||
cd "$DEPLOY_DIR" && docker compose stop
|
||||
- |
|
||||
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
|
||||
DEPLOY_DIR="/opt/g1flux/$BRANCH"
|
||||
DOMAIN=$(grep '^APP_DOMAIN=' "$DEPLOY_DIR/.env" | cut -d= -f2)
|
||||
|
||||
# Certificat TLS (acme.sh, idempotent)
|
||||
# Exit 0 = emis/renouvele, exit 2 = skip (cert valide), autres = erreur
|
||||
ACME_EXIT=0
|
||||
docker exec sonic-acme-1 /app/acme.sh \
|
||||
--home /etc/acme.sh \
|
||||
--issue -d "$DOMAIN" \
|
||||
--webroot /usr/share/nginx/html \
|
||||
--server letsencrypt \
|
||||
--accountemail support+acme@asycn.io || ACME_EXIT=$?
|
||||
if [ "$ACME_EXIT" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
|
||||
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT)"
|
||||
exit 1
|
||||
fi
|
||||
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
|
||||
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
|
||||
echo "Cert TLS OK (acme exit $ACME_EXIT)"
|
||||
- |
|
||||
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
|
||||
DEPLOY_DIR="/opt/g1flux/$BRANCH"
|
||||
cd "$DEPLOY_DIR" && docker compose up -d --remove-orphans
|
||||
docker compose ps
|
||||
|
||||
# Etape 7 : Verification que le container est running
|
||||
- name: test-deploy
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/g1flux:/opt/g1flux
|
||||
commands:
|
||||
- |
|
||||
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
|
||||
DEPLOY_DIR="/opt/g1flux/$BRANCH"
|
||||
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' "$DEPLOY_DIR/.env" | cut -d= -f2)
|
||||
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-app" 2>/dev/null || echo "absent")
|
||||
echo "$PROJECT-app : $STATUS"
|
||||
[ "$STATUS" = "running" ] || { echo "FAIL: container non running"; exit 1; }
|
||||
echo "PASS: container running"
|
||||
|
||||
# Etape 8 : Healthcheck HTTP public
|
||||
- name: healthcheck
|
||||
image: alpine:3.20
|
||||
commands:
|
||||
- apk add --no-cache --quiet curl
|
||||
- |
|
||||
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
|
||||
TARGET="https://$SITE"
|
||||
echo "Healthcheck $TARGET..."
|
||||
MAX=18
|
||||
i=0
|
||||
until [ $i -ge $MAX ]; do
|
||||
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
|
||||
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
|
||||
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
|
||||
echo "PASS: app repond sur $TARGET"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i+1))
|
||||
sleep 10
|
||||
done
|
||||
echo "FAIL: app ne repond pas apres 3 minutes"
|
||||
exit 1
|
||||
|
||||
# Notification en cas d'echec
|
||||
- name: notify-failure
|
||||
image: alpine:3.20
|
||||
commands:
|
||||
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
||||
when:
|
||||
- status: failure
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN VITE_USE_LIVE_API=true npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,18 @@
|
||||
name: ${COMPOSE_PROJECT_NAME:-syoul-g1flux-main}
|
||||
|
||||
services:
|
||||
app:
|
||||
image: g1flux:latest
|
||||
container_name: ${COMPOSE_PROJECT_NAME:-syoul-g1flux-main}-app
|
||||
restart: always
|
||||
labels:
|
||||
- SERVICE_80_NAME=${COMPOSE_PROJECT_NAME:-syoul-g1flux-main}-app-80
|
||||
- SERVICE_80_TAGS=urlprefix-${APP_DOMAIN}/*
|
||||
- SERVICE_80_CHECK_TCP=true
|
||||
- LETSENCRYPT_HOST=${APP_DOMAIN}
|
||||
networks:
|
||||
- sonic
|
||||
|
||||
networks:
|
||||
sonic:
|
||||
external: true
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
}
|
||||
+57
-6
@@ -2,9 +2,12 @@ import { useState, useEffect } from 'react';
|
||||
import { StatsPanel } from './components/StatsPanel';
|
||||
import { PeriodSelector } from './components/PeriodSelector';
|
||||
import { HeatMap } from './components/HeatMap';
|
||||
import { AnimationPlayer } from './components/AnimationPlayer';
|
||||
import { fetchData } from './services/DataService';
|
||||
import type { PeriodStats } from './services/DataService';
|
||||
import type { Transaction } from './data/mockData';
|
||||
import { computeStats } from './data/mockData';
|
||||
import { useAnimation } from './hooks/useAnimation';
|
||||
|
||||
export default function App() {
|
||||
const [periodDays, setPeriodDays] = useState(7);
|
||||
@@ -14,6 +17,15 @@ export default function App() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [source, setSource] = useState<'live' | 'mock'>('mock');
|
||||
const [currentUD, setCurrentUD] = useState<number>(11.78);
|
||||
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
||||
|
||||
const animation = useAnimation(transactions, periodDays, allTimestamps);
|
||||
|
||||
const handlePeriodChange = (days: number) => {
|
||||
animation.deactivate();
|
||||
setPeriodDays(days);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -22,11 +34,13 @@ export default function App() {
|
||||
if (showLoading) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
fetchData(periodDays)
|
||||
.then(({ transactions, stats, source }) => {
|
||||
.then(({ transactions, stats, source, currentUD, allTimestamps }) => {
|
||||
if (!cancelled) {
|
||||
setTransactions(transactions);
|
||||
setStats(stats);
|
||||
setSource(source);
|
||||
setCurrentUD(currentUD);
|
||||
setAllTimestamps(allTimestamps);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
})
|
||||
@@ -42,22 +56,44 @@ export default function App() {
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [periodDays]);
|
||||
|
||||
// Stats calculées sur la fenêtre courante en mode animation
|
||||
const visibleStats: PeriodStats | null = animation.active
|
||||
? {
|
||||
...computeStats(animation.visibleTransactions),
|
||||
geoCount: animation.visibleTransactions.length,
|
||||
// frameTotalCount = total réel (géo + non-géo) dans cette frame
|
||||
transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length,
|
||||
}
|
||||
: stats;
|
||||
|
||||
return (
|
||||
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
||||
{/* Side panel */}
|
||||
<StatsPanel stats={stats} loading={loading} periodDays={periodDays} source={source} />
|
||||
<StatsPanel
|
||||
stats={visibleStats}
|
||||
loading={loading}
|
||||
periodDays={periodDays}
|
||||
source={source}
|
||||
currentUD={currentUD}
|
||||
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
||||
/>
|
||||
|
||||
{/* Map area */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<HeatMap transactions={transactions} />
|
||||
<HeatMap transactions={animation.visibleTransactions} />
|
||||
|
||||
{/* Period selector — floating over map */}
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
||||
<PeriodSelector value={periodDays} onChange={setPeriodDays} />
|
||||
<PeriodSelector
|
||||
value={periodDays}
|
||||
onChange={handlePeriodChange}
|
||||
animationActive={animation.active}
|
||||
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transaction count + source badge */}
|
||||
{!loading && (
|
||||
{/* Transaction count + source badge (masqués en mode animation) */}
|
||||
{!loading && !animation.active && (
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
||||
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
||||
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||
@@ -74,6 +110,21 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animation player */}
|
||||
{animation.active && (
|
||||
<AnimationPlayer
|
||||
frames={animation.frames}
|
||||
currentIndex={animation.currentIndex}
|
||||
playing={animation.playing}
|
||||
speed={animation.speed}
|
||||
onSeek={animation.seek}
|
||||
onPlay={animation.play}
|
||||
onPause={animation.pause}
|
||||
onSpeedChange={animation.setSpeed}
|
||||
onClose={animation.deactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { TimeFrame } from '../hooks/useAnimation';
|
||||
|
||||
interface AnimationPlayerProps {
|
||||
frames: TimeFrame[];
|
||||
currentIndex: number;
|
||||
playing: boolean;
|
||||
speed: 1 | 2 | 4;
|
||||
onSeek: (i: number) => void;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSpeedChange: (s: 1 | 2 | 4) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AnimationPlayer({
|
||||
frames,
|
||||
currentIndex,
|
||||
playing,
|
||||
speed,
|
||||
onSeek,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSpeedChange,
|
||||
onClose,
|
||||
}: AnimationPlayerProps) {
|
||||
const frame = frames[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
|
||||
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
|
||||
|
||||
{/* Frame label + position */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[#d4a843] text-sm font-medium">
|
||||
{frame?.label ?? '—'}
|
||||
</span>
|
||||
<span className="text-[#4b5563] text-xs tabular-nums">
|
||||
{currentIndex + 1} / {frames.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={frames.length - 1}
|
||||
value={currentIndex}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
className="w-full h-1 accent-[#d4a843] cursor-pointer"
|
||||
/>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
{/* Playback buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onSeek(Math.max(0, currentIndex - 1))}
|
||||
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
|
||||
title="Frame précédente"
|
||||
>
|
||||
◀◀
|
||||
</button>
|
||||
<button
|
||||
onClick={playing ? onPause : onPlay}
|
||||
className="px-4 py-1.5 bg-[#d4a843] text-[#0a0b0f] rounded-lg font-bold text-sm hover:bg-[#e8c060] transition-colors min-w-[52px] text-center shadow-[0_0_10px_rgba(212,168,67,0.3)]"
|
||||
>
|
||||
{playing ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSeek(Math.min(frames.length - 1, currentIndex + 1))}
|
||||
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
|
||||
title="Frame suivante"
|
||||
>
|
||||
▶▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Speed selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
|
||||
{([1, 2, 4] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onSpeedChange(s)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
speed === s
|
||||
? 'bg-[#d4a843] text-[#0a0b0f]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}`}
|
||||
>
|
||||
×{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[#4b5563] hover:text-white transition-colors px-2 py-1 text-sm ml-2"
|
||||
title="Quitter l'animation"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+79
-10
@@ -33,6 +33,12 @@ export function HeatMap({ transactions }: HeatMapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const heatRef = useRef<L.HeatLayer | null>(null);
|
||||
// Two img overlays that cross-fade between each other.
|
||||
// The canvas opacity is NEVER touched — it stays at leaflet's default.
|
||||
const prevRef = useRef<HTMLImageElement | null>(null);
|
||||
const nextRef = useRef<HTMLImageElement | null>(null);
|
||||
// Src of the currently visible frame (so prev can be initialised correctly)
|
||||
const currentSrcRef = useRef<string>('');
|
||||
|
||||
// Initialize map once
|
||||
useEffect(() => {
|
||||
@@ -64,32 +70,95 @@ export function HeatMap({ transactions }: HeatMapProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update heatmap data when transactions change
|
||||
// Crossfade: two img overlays swap roles each frame.
|
||||
// Canvas is never hidden — we only read its pixel data via toDataURL().
|
||||
useEffect(() => {
|
||||
if (!heatRef.current || !mapRef.current) return;
|
||||
|
||||
// Normalize amounts for intensity (log scale feels better visually)
|
||||
const maxAmount = Math.max(...transactions.map((t) => t.amount), 1);
|
||||
const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas;
|
||||
const prev = prevRef.current;
|
||||
const next = nextRef.current;
|
||||
|
||||
const draw = () => {
|
||||
const maxAmount = Math.max(...transactions.map((t) => t.amount), 1);
|
||||
const points: L.HeatLatLngTuple[] = transactions.map((tx) => [
|
||||
tx.lat,
|
||||
tx.lng,
|
||||
Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1),
|
||||
]);
|
||||
|
||||
// Guard: only update if the heat layer is still attached to the map
|
||||
try {
|
||||
heatRef.current.setLatLngs(points);
|
||||
heatRef.current?.setLatLngs(points);
|
||||
} catch {
|
||||
// map was torn down (React StrictMode double-invoke), ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (!canvas || !prev || !next) {
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Phase 1 (synchronous): set start state ---
|
||||
// prev shows the current frame (or nothing on first run)
|
||||
prev.src = currentSrcRef.current;
|
||||
prev.style.transition = 'none';
|
||||
prev.style.opacity = currentSrcRef.current ? '1' : '0';
|
||||
|
||||
// next is hidden and will receive the incoming frame
|
||||
next.style.transition = 'none';
|
||||
next.style.opacity = '0';
|
||||
|
||||
void prev.offsetWidth; // flush CSS so transitions start cleanly
|
||||
|
||||
// Ask leaflet to draw new data (schedules an internal RAF)
|
||||
draw();
|
||||
|
||||
// --- Phase 2 (after leaflet redraws): capture new frame, start crossfade ---
|
||||
// leaflet.heat schedules its own RAF inside draw() above.
|
||||
// Our raf1 is queued *after* leaflet's RAF, so when raf1 fires,
|
||||
// leaflet has already redrawn the canvas.
|
||||
let raf2 = 0;
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
let src: string;
|
||||
try {
|
||||
src = canvas.toDataURL();
|
||||
} catch {
|
||||
return; // map torn down
|
||||
}
|
||||
|
||||
currentSrcRef.current = src;
|
||||
next.src = src;
|
||||
void next.offsetWidth; // ensure img is decoded before transition
|
||||
|
||||
const DUR = '0.55s ease-in-out';
|
||||
prev.style.transition = `opacity ${DUR}`;
|
||||
prev.style.opacity = '0';
|
||||
next.style.transition = `opacity ${DUR}`;
|
||||
next.style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); };
|
||||
}, [transactions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
style={{ minHeight: 0 }}
|
||||
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
{/* prev: outgoing frame */}
|
||||
<img
|
||||
ref={prevRef}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ opacity: 0, zIndex: 500 }}
|
||||
/>
|
||||
{/* next: incoming frame — sits on top of prev during crossfade */}
|
||||
<img
|
||||
ref={nextRef}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ opacity: 0, zIndex: 501 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
value: number;
|
||||
onChange: (days: number) => void;
|
||||
animationActive: boolean;
|
||||
onAnimate: () => void;
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
@@ -9,16 +13,40 @@ const PERIODS = [
|
||||
{ label: '30 jours', days: 30 },
|
||||
];
|
||||
|
||||
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||
const PRESET_DAYS = new Set([1, 7, 30]);
|
||||
|
||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [inputVal, setInputVal] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Ouvre le champ custom avec la valeur courante pré-remplie
|
||||
const openCustom = () => {
|
||||
setInputVal(PRESET_DAYS.has(value) ? '' : String(value));
|
||||
setCustomOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) inputRef.current?.focus();
|
||||
}, [customOpen]);
|
||||
|
||||
const commit = () => {
|
||||
const n = parseInt(inputVal, 10);
|
||||
if (n >= 1 && n <= 365) onChange(n);
|
||||
setCustomOpen(false);
|
||||
};
|
||||
|
||||
const isCustomActive = !PRESET_DAYS.has(value);
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1">
|
||||
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
|
||||
{PERIODS.map(({ label, days }) => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => onChange(days)}
|
||||
onClick={() => { onChange(days); setCustomOpen(false); }}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${value === days
|
||||
${value === days && !customOpen
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
@@ -27,6 +55,58 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||
|
||||
{/* Bouton Personnaliser + champ inline */}
|
||||
{customOpen ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
if (e.key === 'Escape') setCustomOpen(false);
|
||||
}}
|
||||
onBlur={commit}
|
||||
placeholder="jours"
|
||||
className="w-16 px-2 py-1 text-sm bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] text-center focus:outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span className="text-[#6b7280] text-xs">j</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={openCustom}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${isCustomActive
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCustomActive ? `${value} jours` : 'Personnaliser'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||
|
||||
<button
|
||||
onClick={onAnimate}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${animationActive
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
▶ Animer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ interface StatsPanelProps {
|
||||
loading: boolean;
|
||||
periodDays: number;
|
||||
source: 'live' | 'mock';
|
||||
currentUD: number;
|
||||
animationLabel?: string;
|
||||
}
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||
@@ -24,7 +26,14 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string;
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) {
|
||||
function formatDU(g1: number, ud: number): string {
|
||||
const du = g1 / ud;
|
||||
if (du < 10) return `≈ ${du.toFixed(2)} DU`;
|
||||
if (du < 100) return `≈ ${du.toFixed(1)} DU`;
|
||||
return `≈ ${Math.round(du).toLocaleString('fr-FR')} DU`;
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel }: StatsPanelProps) {
|
||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||
const prevStats = useRef<PeriodStats | null>(null);
|
||||
|
||||
@@ -46,9 +55,6 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
||||
|
||||
// Mémorise les stats après le rendu
|
||||
if (stats && !loading) prevStats.current = stats;
|
||||
const geoPct = stats && stats.transactionCount > 0
|
||||
? Math.round((stats.geoCount / stats.transactionCount) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
|
||||
@@ -63,9 +69,17 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[#6b7280] text-xs leading-relaxed border-t border-[#1e1f2a] pt-3">
|
||||
Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
|
||||
</p>
|
||||
|
||||
{/* Period label */}
|
||||
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
|
||||
Période : <span className="text-[#6b7280]">{periodLabel}</span>
|
||||
{animationLabel
|
||||
? <><span className="text-[#d4a843]">▶</span> <span className="text-[#d4a843]">{animationLabel}</span></>
|
||||
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -80,16 +94,22 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
||||
<StatCard
|
||||
label="Volume total"
|
||||
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
|
||||
sub={formatDU(stats.totalVolume, currentUD)}
|
||||
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
|
||||
/>
|
||||
<StatCard
|
||||
label="Transactions"
|
||||
value={stats.transactionCount.toLocaleString('fr-FR')}
|
||||
sub={`≈ ${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`}
|
||||
sub={(() => {
|
||||
const avg = stats.totalVolume / (stats.transactionCount || 1);
|
||||
return `≈ ${avg.toFixed(2)} Ğ1 / tx · ${formatDU(avg, currentUD)} / tx`;
|
||||
})()}
|
||||
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
|
||||
/>
|
||||
{/* Couverture géo — uniquement en mode live */}
|
||||
{source === 'live' && geoPct !== null && (
|
||||
{/* Couverture géo — transactionCount inclut le total réel de la frame */}
|
||||
{source === 'live' && stats.transactionCount > 0 && (() => {
|
||||
const pct = Math.round((stats.geoCount / stats.transactionCount) * 100);
|
||||
return (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
|
||||
@@ -98,12 +118,13 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
||||
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${geoPct}%` }}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[#4b5563] text-xs mt-1 text-right">{geoPct}% via Cesium+</p>
|
||||
<p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -142,10 +163,21 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto pt-4 border-t border-[#1e1f2a]">
|
||||
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
|
||||
<p className="text-[#2e2f3a] text-xs text-center">
|
||||
{source === 'live' ? 'Ğ1v2 · Subsquid + Cesium+' : 'Données simulées · mock'}
|
||||
</p>
|
||||
<a
|
||||
href="https://git.open.us.org/syoul/g1flux"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-[#d4a843] hover:text-[#e8c060] text-xs text-center transition-colors"
|
||||
>
|
||||
git.open.us.org/syoul/g1flux
|
||||
</a>
|
||||
<p className="text-[#d4a843] text-xs text-center">
|
||||
Logiciel libre sous licence AGPLv3
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -85,11 +85,15 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
||||
return transactions.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
const POOL_GENERATED_AT = Date.now();
|
||||
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
export function getTransactionsForPeriod(periodDays: number): Transaction[] {
|
||||
const drift = Date.now() - POOL_GENERATED_AT;
|
||||
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
|
||||
return TRANSACTION_POOL.filter((tx) => tx.timestamp >= cutoff);
|
||||
return TRANSACTION_POOL
|
||||
.map((tx) => ({ ...tx, timestamp: tx.timestamp + drift }))
|
||||
.filter((tx) => tx.timestamp >= cutoff);
|
||||
}
|
||||
|
||||
export function computeStats(transactions: Transaction[]) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { Transaction } from '../data/mockData';
|
||||
|
||||
export interface TimeFrame {
|
||||
label: string;
|
||||
from: number; // Unix ms
|
||||
to: number; // Unix ms
|
||||
}
|
||||
|
||||
function buildFrames(periodDays: number): TimeFrame[] {
|
||||
const now = Date.now();
|
||||
const start = now - periodDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
|
||||
new Date(ms).toLocaleDateString('fr-FR', opts);
|
||||
|
||||
if (periodDays === 1) {
|
||||
return Array.from({ length: 24 }, (_, i) => {
|
||||
const from = start + i * 3_600_000;
|
||||
const to = from + 3_600_000;
|
||||
const h = new Date(from).getHours();
|
||||
return {
|
||||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h – ${h + 1}h`,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (periodDays === 7) {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const from = start + i * 86_400_000;
|
||||
const to = from + 86_400_000;
|
||||
return {
|
||||
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
|
||||
from,
|
||||
to,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 30 days → half-week frames (3.5 days ≈ 9–10 frames)
|
||||
const HALF_WEEK = 3.5 * 86_400_000;
|
||||
const frames: TimeFrame[] = [];
|
||||
let cursor = start;
|
||||
while (cursor < now) {
|
||||
const from = cursor;
|
||||
const to = Math.min(cursor + HALF_WEEK, now);
|
||||
frames.push({
|
||||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} – ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
cursor = to;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function useAnimation(transactions: Transaction[], periodDays: number, allTimestamps: number[] = []) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<1 | 2 | 4>(2);
|
||||
|
||||
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
|
||||
|
||||
// Reset cursor when period or activation changes.
|
||||
// Stop playback only on deactivation — not on activation, so activate() can
|
||||
// start playing immediately without being overridden by this effect.
|
||||
useEffect(() => {
|
||||
setCurrentIndex(0);
|
||||
if (!active) setPlaying(false);
|
||||
}, [periodDays, active]);
|
||||
|
||||
// Auto-advance: one step every (2000 / speed) ms
|
||||
useEffect(() => {
|
||||
if (!playing || !active) return;
|
||||
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
|
||||
const t = setTimeout(() => {
|
||||
setCurrentIndex((i) => {
|
||||
if (i >= frames.length - 1) {
|
||||
setPlaying(false);
|
||||
return i;
|
||||
}
|
||||
return i + 1;
|
||||
});
|
||||
}, delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [playing, active, currentIndex, speed, frames.length]);
|
||||
|
||||
const visibleTransactions = useMemo(() => {
|
||||
if (!active || frames.length === 0) return transactions;
|
||||
const frame = frames[currentIndex];
|
||||
if (!frame) return transactions;
|
||||
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
|
||||
}, [active, transactions, frames, currentIndex]);
|
||||
|
||||
// Nombre total de transfers (géo + non-géo) dans la frame courante
|
||||
const frameTotalCount = useMemo(() => {
|
||||
if (!active || frames.length === 0 || allTimestamps.length === 0) return null;
|
||||
const frame = frames[currentIndex];
|
||||
if (!frame) return null;
|
||||
return allTimestamps.filter((ts) => ts >= frame.from && ts < frame.to).length;
|
||||
}, [active, allTimestamps, frames, currentIndex]);
|
||||
|
||||
return {
|
||||
active,
|
||||
activate: () => { setActive(true); setSpeed(1); setPlaying(true); },
|
||||
deactivate: () => { setActive(false); },
|
||||
playing,
|
||||
play: () => setPlaying(true),
|
||||
pause: () => setPlaying(false),
|
||||
currentIndex,
|
||||
seek: (i: number) => { setCurrentIndex(i); setPlaying(false); },
|
||||
speed,
|
||||
setSpeed,
|
||||
frames,
|
||||
currentFrame: frames[currentIndex] ?? null,
|
||||
visibleTransactions,
|
||||
frameTotalCount,
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
|
||||
*/
|
||||
|
||||
import { fetchTransfers, buildIdentityKeyMap } from './adapters/SubsquidAdapter';
|
||||
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
|
||||
import { resolveGeoByKeys } from './adapters/CesiumAdapter';
|
||||
import {
|
||||
getTransactionsForPeriod,
|
||||
@@ -22,6 +22,16 @@ import {
|
||||
|
||||
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
|
||||
|
||||
// Cache du DU courant, valide 1 heure (le DU change tous les ~6 mois)
|
||||
let udCache: { value: number; expiresAt: number } | null = null;
|
||||
|
||||
async function getCurrentUD(): Promise<number> {
|
||||
if (udCache && Date.now() < udCache.expiresAt) return udCache.value;
|
||||
const value = await fetchCurrentUD();
|
||||
udCache = { value, expiresAt: Date.now() + 60 * 60 * 1000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
// Cache de la carte identité SS58→DuniterKey, valide 10 minutes
|
||||
let keyMapCache: { map: Map<string, string>; expiresAt: number } | null = null;
|
||||
|
||||
@@ -36,9 +46,12 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
geolocated: Transaction[];
|
||||
totalCount: number;
|
||||
totalVolume: number;
|
||||
allTimestamps: number[];
|
||||
}> {
|
||||
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays);
|
||||
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 };
|
||||
// ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
|
||||
const limit = Math.max(2000, Math.ceil(periodDays * 600));
|
||||
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit);
|
||||
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
|
||||
|
||||
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
|
||||
|
||||
@@ -87,7 +100,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
return { geolocated, totalCount, totalVolume };
|
||||
return { geolocated, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -104,6 +117,8 @@ export interface DataResult {
|
||||
transactions: Transaction[]; // uniquement géolocalisées → heatmap
|
||||
stats: PeriodStats;
|
||||
source: 'live' | 'mock';
|
||||
currentUD: number; // valeur du DU courant en Ğ1
|
||||
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
|
||||
}
|
||||
|
||||
export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
@@ -115,10 +130,15 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
transactions,
|
||||
stats: { ...base, geoCount: transactions.length },
|
||||
source: 'mock',
|
||||
currentUD: 11.78,
|
||||
allTimestamps: transactions.map((t) => t.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
const { geolocated, totalCount, totalVolume } = await fetchLiveTransactions(periodDays);
|
||||
const [{ geolocated, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
|
||||
fetchLiveTransactions(periodDays),
|
||||
getCurrentUD(),
|
||||
]);
|
||||
const base = computeStats(geolocated);
|
||||
|
||||
return {
|
||||
@@ -130,5 +150,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
topCities: base.topCities,
|
||||
},
|
||||
source: 'live',
|
||||
currentUD,
|
||||
allTimestamps,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ function countryCodeFromCity(city: string): string {
|
||||
const HitSchema = z.object({
|
||||
_id: z.string(),
|
||||
_source: z.object({
|
||||
title: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
city: z.string().nullable().optional(),
|
||||
geoPoint: z.unknown().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
|
||||
// ---------------------------------------------------------------------------
|
||||
const SubsquidTransferNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
blockNumber: z.number().int().positive(),
|
||||
blockNumber: z.number().int(), // peut être négatif pour les blocs Ğ1v1 migrés
|
||||
timestamp: z.string(), // ISO 8601 ex: "2026-03-22T14:53:36+00:00"
|
||||
amount: z.string(), // BigInt en string, en centimes Ğ1
|
||||
fromId: z.string().nullable(),
|
||||
@@ -153,6 +153,27 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Retourne la valeur du DU courant en Ğ1 (ex : 11.78). Fallback hardcodé si indisponible. */
|
||||
export async function fetchCurrentUD(): Promise<number> {
|
||||
const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue
|
||||
try {
|
||||
const response = await fetch(SUBSQUID_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: `{ universalDividends(orderBy: BLOCK_NUMBER_DESC, first: 1) { nodes { amount } } }`,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return UD_FALLBACK;
|
||||
const raw = await response.json();
|
||||
const amountStr: string | undefined = raw?.data?.universalDividends?.nodes?.[0]?.amount;
|
||||
if (!amountStr) return UD_FALLBACK;
|
||||
return parseInt(amountStr, 10) / 100;
|
||||
} catch {
|
||||
return UD_FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchTransfersResult {
|
||||
transfers: RawTransfer[];
|
||||
totalCount: number;
|
||||
|
||||
Reference in New Issue
Block a user