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

View File

@@ -0,0 +1,146 @@
<template>
<div ref="containerRef" class="gw-map" />
</template>
<script setup lang="ts">
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import '@geoman-io/leaflet-geoman-free';
import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css';
import type { GeoMember } from '~/composables/useCesiumProfiles';
type LoadPolygon = { coords: [number, number][]; name: string } | null;
const props = defineProps<{
members: GeoMember[];
clearTrigger?: number;
loadPolygon?: LoadPolygon;
}>();
const emit = defineEmits<{
polygonChange: [polygon: [number, number][] | null, name?: string];
}>();
const containerRef = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let cluster: any = null;
let polygonLayer: L.Layer | null = null;
onMounted(() => {
if (!containerRef.value) return;
map = L.map(containerRef.value).setView([46.5, 2.5], 6);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
cluster = (L as any).markerClusterGroup();
map.addLayer(cluster);
// Geoman controls
if (map.pm) {
map.pm.addControls({
position: 'topleft',
drawCircle: false,
drawCircleMarker: false,
drawMarker: false,
drawPolyline: false,
drawText: false,
editMode: false,
dragMode: false,
cutPolygon: false,
rotateMode: false,
removalMode: false,
});
map.pm.setLang('fr');
}
map.on('pm:create', (e: any) => {
if (polygonLayer) {
map!.removeLayer(polygonLayer);
}
polygonLayer = e.layer;
const latlngs = (e.layer as L.Polygon).getLatLngs()[0] as L.LatLng[];
emit('polygonChange', latlngs.map((ll) => [ll.lat, ll.lng]));
});
map.on('pm:remove', () => {
polygonLayer = null;
emit('polygonChange', null);
});
// Populate initial markers
updateMarkers(props.members);
});
onUnmounted(() => {
if (map) {
map.remove();
map = null;
cluster = null;
}
});
// Clear polygon when clearTrigger changes
watch(() => props.clearTrigger, () => {
if (!props.clearTrigger || !map) return;
if (polygonLayer) {
const center = map.getCenter();
const zoom = map.getZoom();
map.removeLayer(polygonLayer);
polygonLayer = null;
map.setView(center, zoom, { animate: false });
}
if (map.pm) {
map.pm.disableDraw();
}
});
// Load a saved polygon
watch(() => props.loadPolygon, (loadPolygon) => {
if (!map || !loadPolygon) return;
if (polygonLayer) {
map.removeLayer(polygonLayer);
polygonLayer = null;
}
const latlngs = loadPolygon.coords.map(([lat, lng]) => L.latLng(lat, lng));
const poly = L.polygon(latlngs, { color: '#3388ff' }).addTo(map);
polygonLayer = poly;
map.fitBounds(poly.getBounds(), { padding: [20, 20] });
emit('polygonChange', loadPolygon.coords, loadPolygon.name);
});
// Update markers when members change
function updateMarkers(members: GeoMember[]) {
if (!cluster) return;
cluster.clearLayers();
const markers = members.map((m) =>
L.circleMarker([m.lat, m.lon], {
radius: 6,
color: '#f59e0b',
fillColor: '#f59e0b',
fillOpacity: 0.7,
}).bindPopup(
`<strong>${m.title || m.pubkey.slice(0, 8)}</strong>${m.city ? '<br>' + m.city : ''}`
)
);
cluster.addLayers(markers);
}
watch(() => props.members, (members) => {
updateMarkers(members);
});
</script>
<style scoped>
.gw-map {
height: 500px;
width: 100%;
border-radius: 0.75rem;
}
</style>