feat: coloration des nœuds par balance nette (orange émetteur / vert récepteur)
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,25 @@
|
|||||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
/** Interpolation RGB linéaire entre deux couleurs hex, t ∈ [0, 1]. */
|
||||||
|
function lerpColor(hex1: string, hex2: string, t: number): string {
|
||||||
|
const parse = (h: string) => [
|
||||||
|
parseInt(h.slice(1, 3), 16),
|
||||||
|
parseInt(h.slice(3, 5), 16),
|
||||||
|
parseInt(h.slice(5, 7), 16),
|
||||||
|
];
|
||||||
|
const [r1, g1, b1] = parse(hex1);
|
||||||
|
const [r2, g2, b2] = parse(hex2);
|
||||||
|
const r = Math.round(r1 + (r2 - r1) * t);
|
||||||
|
const g = Math.round(g1 + (g2 - g1) * t);
|
||||||
|
const b = Math.round(b1 + (b2 - b1) * t);
|
||||||
|
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
|
||||||
|
const COLOR_NEG = '#ff6d00'; // orange vif
|
||||||
|
const COLOR_POS = '#00c853'; // vert vif
|
||||||
|
const NEUTRAL_THRESHOLD = 0.05; // ±5 % → couleur neutre
|
||||||
import type { TransactionArc } from '../data/arcData';
|
import type { TransactionArc } from '../data/arcData';
|
||||||
import { buildCorridors } from '../data/arcData';
|
import { buildCorridors } from '../data/arcData';
|
||||||
|
|
||||||
@@ -83,6 +103,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
|
|
||||||
const maxVol = Math.max(...corridors.map(c => c.totalVolume), 1);
|
const maxVol = Math.max(...corridors.map(c => c.totalVolume), 1);
|
||||||
const maxNodeVol = Math.max(...[...cityNodes.values()].map(c => c.emitted + c.received), 1);
|
const maxNodeVol = Math.max(...[...cityNodes.values()].map(c => c.emitted + c.received), 1);
|
||||||
|
const maxAbsNet = Math.max(...[...cityNodes.values()].map(d => Math.abs(d.received - d.emitted)), 1);
|
||||||
|
|
||||||
// ---- Arcs ----
|
// ---- Arcs ----
|
||||||
const arcElems = corridors.map((c, idx) => {
|
const arcElems = corridors.map((c, idx) => {
|
||||||
@@ -139,7 +160,17 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
const p = proj(data.lat, data.lng);
|
const p = proj(data.lat, data.lng);
|
||||||
const vol = data.emitted + data.received;
|
const vol = data.emitted + data.received;
|
||||||
const r = Math.max(3, Math.min(14, 3 + 9 * (vol / maxNodeVol)));
|
const r = Math.max(3, Math.min(14, 3 + 9 * (vol / maxNodeVol)));
|
||||||
return { name, p, r, isSelected: focusCity === name };
|
|
||||||
|
// Couleur selon balance nette normalisée
|
||||||
|
const net = data.received - data.emitted;
|
||||||
|
const t = net / maxAbsNet; // ∈ [-1, 1]
|
||||||
|
const fillColor = Math.abs(t) < NEUTRAL_THRESHOLD
|
||||||
|
? COLOR_NEUTRAL
|
||||||
|
: t < 0
|
||||||
|
? lerpColor(COLOR_NEUTRAL, COLOR_NEG, -t)
|
||||||
|
: lerpColor(COLOR_NEUTRAL, COLOR_POS, t);
|
||||||
|
|
||||||
|
return { name, p, r, fillColor, isSelected: focusCity === name };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { arcElems, nodeElems };
|
return { arcElems, nodeElems };
|
||||||
@@ -194,8 +225,8 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
cx={node.p.x}
|
cx={node.p.x}
|
||||||
cy={node.p.y}
|
cy={node.p.y}
|
||||||
r={node.r}
|
r={node.r}
|
||||||
fill={node.isSelected ? '#ffffff' : '#d4a843'}
|
fill={node.isSelected ? '#ffffff' : node.fillColor}
|
||||||
stroke={node.isSelected ? '#d4a843' : '#0a0b0f'}
|
stroke={node.isSelected ? node.fillColor : '#0a0b0f'}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => onCityClick(focusCity === node.name ? null : node.name)}
|
onClick={() => onCityClick(focusCity === node.name ? null : node.name)}
|
||||||
|
|||||||
@@ -247,7 +247,13 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
{/* Balance nette */}
|
{/* Balance nette */}
|
||||||
{flowStats.netBalance.length > 0 && (
|
{flowStats.netBalance.length > 0 && (
|
||||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
|
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
|
||||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
|
||||||
|
<p className="text-[10px] text-[#4b5563] flex items-center gap-1.5">
|
||||||
|
<span style={{ color: '#ff6d00' }}>●</span>émetteur
|
||||||
|
<span style={{ color: '#00c853' }}>●</span>récepteur
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{flowStats.netBalance.map((c) => (
|
{flowStats.netBalance.map((c) => (
|
||||||
<div key={c.city} className="flex items-center justify-between">
|
<div key={c.city} className="flex items-center justify-between">
|
||||||
<span className="text-white text-xs truncate">{c.city}</span>
|
<span className="text-white text-xs truncate">{c.city}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user