Migrate grateWizard from React/Next.js to native Nuxt integration

- 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>
This commit is contained in:
Yvv
2026-02-21 15:33:01 +01:00
parent 524c7a0fc2
commit 2b5543791f
93 changed files with 2186 additions and 585 deletions

69
app/utils/gratewizard.ts Normal file
View File

@@ -0,0 +1,69 @@
/** Ray-casting algorithm to test if a point is inside a polygon */
export function pointInPolygon(lat: number, lng: number, polygon: [number, number][]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [yi, xi] = polygon[i];
const [yj, xj] = polygon[j];
if ((yi > lat) !== (yj > lat) && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
/** Format a number in French locale */
export const fr = (n: number, decimals = 2) =>
n.toLocaleString('fr-FR', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
export type CurrencyUnit = 'DU' | 'G1';
/** Format a G1 value in the given unit, with k/M suffix */
export function formatValue(g1Value: number, unit: CurrencyUnit, duDaily: number): string {
const val = unit === 'DU' ? g1Value / duDaily : g1Value;
const suffix = unit === 'DU' ? 'DU' : '\u011e1';
if (val >= 1_000_000) return fr(val / 1_000_000) + ' M' + suffix;
if (val >= 1_000) return fr(val / 1_000) + ' k' + suffix;
return fr(val) + ' ' + suffix;
}
/** Binary-search count of udBlocks entries >= joinBlock (udBlocks is sorted ascending). */
export function countUdSince(udBlocks: number[], joinBlock: number): number {
let lo = 0, hi = udBlocks.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (udBlocks[mid] < joinBlock) lo = mid + 1;
else hi = mid;
}
return udBlocks.length - lo;
}
/** Date to ISO-like string (yyyy-mm-dd) */
export const dateToString = (date: Date) =>
date.getFullYear() + '-' + ('0' + (date.getMonth() + 1)).slice(-2) + '-' + ('0' + date.getDate()).slice(-2);
/** Number of days between a date and today */
export const getDays = (date: string | undefined) => {
if (!date) return 0;
const d = new Date(date);
const today = new Date();
return Math.floor(Math.abs(d.getTime() - today.getTime()) / (1000 * 3600 * 24));
};
/** Seniority ratio between two dates (days from today) */
export const getRatio = (date1: string | undefined, date2: string | undefined) => {
return getDays(date1) / Math.max(getDays(date2), 1);
};
export const Block0Date = '2017-03-08';
export type Friend = {
name: string;
date: string;
};
export type TableFriend = Friend & {
[key: string]: string | number;
displayName: string;
displayDate: string;
du: number;
};

109
app/utils/ss58.ts Normal file
View File

@@ -0,0 +1,109 @@
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ALPHABET_MAP = new Map<string, number>();
for (let i = 0; i < BASE58_ALPHABET.length; i++) {
ALPHABET_MAP.set(BASE58_ALPHABET[i], i);
}
export function base58Decode(str: string): Uint8Array {
if (str.length === 0) return new Uint8Array(0);
// Count leading '1's (zero bytes)
let leadingZeros = 0;
while (leadingZeros < str.length && str[leadingZeros] === '1') {
leadingZeros++;
}
// Decode base58 to big integer (stored as byte array)
const size = Math.ceil(str.length * 733 / 1000) + 1; // log(58) / log(256)
const bytes = new Uint8Array(size);
for (let i = leadingZeros; i < str.length; i++) {
const val = ALPHABET_MAP.get(str[i]);
if (val === undefined) throw new Error(`Invalid base58 character: ${str[i]}`);
let carry = val;
for (let j = size - 1; j >= 0; j--) {
carry += 256 * bytes[j];
bytes[j] = carry % 256;
carry = Math.floor(carry / 256);
}
}
// Skip leading zeros in the decoded bytes
let start = 0;
while (start < size && bytes[start] === 0) {
start++;
}
const result = new Uint8Array(leadingZeros + (size - start));
// Leading zeros from '1' characters
for (let i = 0; i < leadingZeros; i++) {
result[i] = 0;
}
// Decoded bytes
for (let i = start; i < size; i++) {
result[leadingZeros + (i - start)] = bytes[i];
}
return result;
}
export function base58Encode(bytes: Uint8Array): string {
if (bytes.length === 0) return '';
// Count leading zero bytes
let leadingZeros = 0;
while (leadingZeros < bytes.length && bytes[leadingZeros] === 0) {
leadingZeros++;
}
// Encode to base58
const size = Math.ceil(bytes.length * 138 / 100) + 1; // log(256) / log(58)
const digits = new Uint8Array(size);
for (let i = leadingZeros; i < bytes.length; i++) {
let carry = bytes[i];
for (let j = size - 1; j >= 0; j--) {
carry += 256 * digits[j];
digits[j] = carry % 58;
carry = Math.floor(carry / 58);
}
}
// Skip leading zeros in base58 output
let start = 0;
while (start < size && digits[start] === 0) {
start++;
}
let result = '1'.repeat(leadingZeros);
for (let i = start; i < size; i++) {
result += BASE58_ALPHABET[digits[i]];
}
return result;
}
/**
* Convert an SS58 address to a base58-encoded raw pubkey (Cesium+ v1 format).
*
* SS58 layout: [prefix (1 or 2 bytes)] [32 bytes pubkey] [2 bytes checksum]
* - If first byte has bit 6 set (& 0x40), prefix is 2 bytes
* - Otherwise prefix is 1 byte
*/
export function ss58ToV1Pubkey(ss58: string): string {
const raw = base58Decode(ss58);
// Determine prefix length
const prefixLen = (raw[0] & 0x40) ? 2 : 1;
// Extract 32-byte pubkey (skip prefix, drop 2-byte checksum)
const pubkey = raw.slice(prefixLen, prefixLen + 32);
if (pubkey.length !== 32) {
throw new Error(`Invalid SS58 address: expected 32-byte pubkey, got ${pubkey.length}`);
}
return base58Encode(pubkey);
}