- Port all React components to Vue 3 (GwTabs, GwMN, GwCRA, GwCRS, GwMap, GwRelations, GwPerimeterList) - Port hooks to Vue composables (useCesiumProfiles, useSavedPerimeters) - Copy pure TS services and utils (duniter/, ss58, gratewizard utils) - Add Leaflet + Geoman + MarkerCluster dependencies - Serve grateWizard as popup via /gratewizard?popup (layout: false) and info page on /gratewizard (with Librodrome layout) - Remove public/gratewizard-app/ static Next.js export - Refine UI: compact tabs, buttons, inputs, cards, perimeter list - Use Ğ1 breve everywhere, French locale for all dates and amounts - Rename roles: vendeur→offre / acheteur→reçoit le produit ou service - Rename prix→évaluation in all visible text - Add calculated result column in CRA and CRS relation tables - DU/Ğ1 selector uses toggle switch (same as role toggle) - Auto-scroll to monetary data card on polygon selection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
10 KiB
Vue
273 lines
10 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-5 pt-4">
|
|
<!-- Error state -->
|
|
<div v-if="error" class="p-8 flex flex-col gap-4">
|
|
<p class="gw-text">Erreur : {{ error }}</p>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- Currency selector -->
|
|
<div class="flex items-center justify-center gap-2">
|
|
<span class="gw-text text-xs">DU</span>
|
|
<label class="gw-toggle">
|
|
<input
|
|
type="checkbox"
|
|
:checked="unit === 'G1'"
|
|
@change="unit = ($event.target as HTMLInputElement).checked ? 'G1' : 'DU'"
|
|
/>
|
|
<span class="gw-toggle-slider" />
|
|
</label>
|
|
<span class="gw-text text-xs">Ğ1</span>
|
|
</div>
|
|
|
|
<!-- Monetary data card -->
|
|
<div ref="topRef" class="card-surface">
|
|
<div class="flex flex-col items-center gap-3">
|
|
<p class="gw-metric">Masse monétaire / Membres</p>
|
|
|
|
<span v-if="polygon" class="gw-chip">
|
|
{{ activePerimeterName ?? 'S\u00e9lection manuelle' }}
|
|
</span>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loading" class="flex flex-col items-center gap-2 py-4">
|
|
<div class="gw-spinner" />
|
|
<p class="gw-text text-sm">Connexion au réseau...</p>
|
|
</div>
|
|
|
|
<!-- Data grid -->
|
|
<div v-else-if="monetary" class="grid grid-cols-2 gap-2 w-full max-w-sm">
|
|
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
|
|
<p class="gw-text">M (masse{{ polygon ? ' locale' : '' }})</p>
|
|
<p class="gw-title mt-1">
|
|
{{ mnIsLoading ? '...' : formatValue(displayM!, unit, duDaily) }}
|
|
</p>
|
|
</div>
|
|
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
|
|
<p class="gw-text">N (membres{{ polygon ? ' locaux' : '' }})</p>
|
|
<p class="gw-title mt-1">{{ displayN.toLocaleString('fr-FR') }}</p>
|
|
</div>
|
|
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
|
|
<p class="gw-text">M / N{{ polygon ? ' (local)' : '' }}</p>
|
|
<p class="gw-title mt-1">
|
|
{{ mnIsLoading ? '...' : formatValue(displayMN!, unit, duDaily) }}
|
|
</p>
|
|
</div>
|
|
<div class="text-center p-2 rounded-md text-xs bg-surface-200">
|
|
<p class="gw-text">DU journalier</p>
|
|
<p class="gw-title mt-1">{{ fr(duDaily) }} Ğ1</p>
|
|
</div>
|
|
|
|
<!-- Extra cells when polygon is active -->
|
|
<template v-if="polygon && nLocal > 0 && monetary">
|
|
<div class="text-center p-2 rounded-md text-xs bg-surface-200">
|
|
<p class="gw-text">M / N (réseau)</p>
|
|
<p class="gw-title mt-1">{{ formatValue(mnG1, unit, duDaily) }}</p>
|
|
</div>
|
|
<div class="text-center p-2 rounded-md text-xs bg-accent/10">
|
|
<p class="gw-text">Part du réseau</p>
|
|
<p class="gw-title mt-1">{{ fr(nLocal / monetary.membersCount * 100) }} %</p>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="col-span-2 text-center">
|
|
<p class="gw-text">
|
|
Bloc #{{ monetary.blockNumber }} —
|
|
{{ new Date(monetary.timestamp).toLocaleDateString('fr-FR', { dateStyle: 'long' }) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Clear selection button -->
|
|
<div v-if="polygon" class="flex flex-col items-center gap-2">
|
|
<button
|
|
class="gw-btn gw-btn-danger"
|
|
@click="handleClearSelection"
|
|
>
|
|
Périmètre monde
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Saved perimeters -->
|
|
<GratewizardGwPerimeterList
|
|
:perimeters="perimeters"
|
|
@load="handleLoadPerimeter"
|
|
@delete="deletePerimeter"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map card -->
|
|
<div class="card-surface">
|
|
<div class="flex flex-col items-center gap-3">
|
|
<p class="gw-metric">
|
|
Carte des membres géolocalisés
|
|
<span v-if="!geoLoading && v1Pubkeys" class="gw-chip ml-2">
|
|
{{ geoMembers.length.toLocaleString('fr-FR') }}
|
|
</span>
|
|
</p>
|
|
|
|
<p v-if="geoError" class="gw-text">Erreur Cesium+ : {{ geoError }}</p>
|
|
|
|
<!-- Loading map -->
|
|
<div v-if="geoLoading || !v1Pubkeys" class="flex flex-col items-center gap-2 py-4">
|
|
<div class="gw-spinner" />
|
|
<p class="gw-text text-sm">Chargement des profils géolocalisés...</p>
|
|
</div>
|
|
|
|
<!-- Map -->
|
|
<template v-else>
|
|
<GratewizardGwMap
|
|
:members="geoMembers"
|
|
:clear-trigger="clearTrigger"
|
|
:load-polygon="loadPolygonTrigger"
|
|
@polygon-change="handlePolygonChange"
|
|
/>
|
|
|
|
<p v-if="!loading && monetary" class="gw-text">
|
|
{{ geoMembers.length.toLocaleString('fr-FR') }} membres certifiés géolocalisés sur
|
|
{{ monetary.membersCount.toLocaleString('fr-FR') }} membres au total
|
|
</p>
|
|
|
|
<!-- Save perimeter form -->
|
|
<div v-if="polygon" class="flex gap-2 items-center">
|
|
<input
|
|
v-model="perimeterName"
|
|
type="text"
|
|
placeholder="Nom du périmètre"
|
|
class="gw-input"
|
|
@keydown.enter="handleSavePerimeter"
|
|
/>
|
|
<button
|
|
class="gw-btn gw-btn-accent"
|
|
:disabled="!perimeterName.trim()"
|
|
@click="handleSavePerimeter"
|
|
>
|
|
Sauvegarder
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { duniter, type MonetaryData } from '~/services/duniter';
|
|
import { pointInPolygon, fr, formatValue, countUdSince, type CurrencyUnit } from '~/utils/gratewizard';
|
|
import type { GeoMember } from '~/composables/useCesiumProfiles';
|
|
import type { SavedPerimeter } from '~/composables/useSavedPerimeters';
|
|
|
|
const unit = useLocalStorage<CurrencyUnit>('gw-currency-unit', 'DU');
|
|
|
|
const monetary = ref<MonetaryData | null>(null);
|
|
const v1Pubkeys = ref<string[] | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
const polygon = ref<[number, number][] | null>(null);
|
|
const clearTrigger = ref(0);
|
|
const loadPolygonTrigger = ref<{ coords: [number, number][]; name: string } | null>(null);
|
|
const perimeterName = ref('');
|
|
const activePerimeterName = ref<string | null>(null);
|
|
const localMNg1 = ref<number | null>(null);
|
|
const mnLoading = ref(false);
|
|
const topRef = ref<HTMLDivElement | null>(null);
|
|
|
|
const { geoMembers, loading: geoLoading, error: geoError } = useCesiumProfiles(v1Pubkeys);
|
|
const { perimeters, savePerimeter, deletePerimeter } = useSavedPerimeters();
|
|
|
|
// Fetch monetary data on mount
|
|
onMounted(async () => {
|
|
try {
|
|
const [mon, pubkeys] = await Promise.all([
|
|
duniter.fetchMonetary(),
|
|
duniter.fetchMemberPubkeys(),
|
|
]);
|
|
monetary.value = mon;
|
|
v1Pubkeys.value = pubkeys;
|
|
} catch (e: any) {
|
|
error.value = e.message;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
});
|
|
|
|
const duDaily = computed(() => monetary.value ? Number(monetary.value.amount) / 100 : 1);
|
|
const massG1 = computed(() => monetary.value ? Number(monetary.value.monetaryMass) / 100 : 0);
|
|
const mnG1 = computed(() => monetary.value && monetary.value.membersCount ? massG1.value / monetary.value.membersCount : 0);
|
|
|
|
const localMembers = computed(() => {
|
|
if (!polygon.value) return [];
|
|
return geoMembers.value.filter((m) => pointInPolygon(m.lat, m.lon, polygon.value!));
|
|
});
|
|
|
|
const nLocal = computed(() => localMembers.value.length);
|
|
|
|
// Compute local M/N based on member seniority
|
|
watch([polygon, localMembers, monetary], async ([poly, members, mon]) => {
|
|
if (!poly || members.length === 0 || !mon) {
|
|
localMNg1.value = null;
|
|
return;
|
|
}
|
|
|
|
mnLoading.value = true;
|
|
|
|
try {
|
|
const pubkeys = members.map((m) => m.pubkey);
|
|
const joinBlocks = await duniter.fetchMemberJoinBlocks(pubkeys);
|
|
|
|
const udBlocks = mon.udBlockNumbers;
|
|
let totalSeniority = 0;
|
|
let validCount = 0;
|
|
|
|
for (const pk of pubkeys) {
|
|
const joinBlock = joinBlocks.get(pk);
|
|
if (joinBlock === undefined) continue;
|
|
totalSeniority += countUdSince(udBlocks, joinBlock);
|
|
validCount++;
|
|
}
|
|
|
|
if (validCount > 0) {
|
|
const localAvgSeniority = totalSeniority / validCount;
|
|
const globalAvgSeniority =
|
|
Number(mon.monetaryMass) / (Number(mon.amount) * mon.membersCount);
|
|
localMNg1.value = mnG1.value * (localAvgSeniority / globalAvgSeniority);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to compute local M/N:', e);
|
|
} finally {
|
|
mnLoading.value = false;
|
|
}
|
|
});
|
|
|
|
const displayN = computed(() => polygon.value ? nLocal.value : monetary.value?.membersCount ?? 0);
|
|
const displayMN = computed(() => polygon.value ? localMNg1.value : mnG1.value);
|
|
const displayM = computed(() => polygon.value ? (localMNg1.value !== null ? localMNg1.value * nLocal.value : null) : massG1.value);
|
|
const mnIsLoading = computed(() => polygon.value && (mnLoading.value || localMNg1.value === null));
|
|
|
|
function handlePolygonChange(poly: [number, number][] | null, name?: string) {
|
|
polygon.value = poly;
|
|
activePerimeterName.value = name ?? null;
|
|
if (poly) nextTick(() => topRef.value?.scrollIntoView({ behavior: 'smooth' }));
|
|
}
|
|
|
|
function handleClearSelection() {
|
|
polygon.value = null;
|
|
activePerimeterName.value = null;
|
|
localMNg1.value = null;
|
|
clearTrigger.value++;
|
|
loadPolygonTrigger.value = null;
|
|
}
|
|
|
|
function handleSavePerimeter() {
|
|
if (!polygon.value || !perimeterName.value.trim()) return;
|
|
savePerimeter(perimeterName.value.trim(), polygon.value);
|
|
perimeterName.value = '';
|
|
}
|
|
|
|
function handleLoadPerimeter(p: SavedPerimeter) {
|
|
loadPolygonTrigger.value = { coords: p.polygon, name: p.name };
|
|
}
|
|
</script>
|