feature gps works
This commit is contained in:
BIN
Binary file not shown.
@@ -9,7 +9,7 @@ export interface EbikeGPSMapProps {
|
|||||||
* If omitted, snaps to [0,0,0]
|
* If omitted, snaps to [0,0,0]
|
||||||
*/
|
*/
|
||||||
startPos?: { x: number; y: number; z: number };
|
startPos?: { x: number; y: number; z: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3D world position of the destination/target (GPS end point)
|
* 3D world position of the destination/target (GPS end point)
|
||||||
*/
|
*/
|
||||||
@@ -59,7 +59,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
const [mapImage, setMapImage] = useState<HTMLImageElement | null>(null);
|
const [mapImage, setMapImage] = useState<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
// Offscreen high-res canvas for crystal clear rendering
|
// Offscreen high-res canvas for crystal clear rendering
|
||||||
const [offscreenCanvas] = useState(() => {
|
const [offscreenCanvas] = useState(() => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -110,7 +110,6 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
|
||||||
img.onload = () => setMapImage(img);
|
img.onload = () => setMapImage(img);
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
console.warn(`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`);
|
console.warn(`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`);
|
||||||
@@ -175,6 +174,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
const canvas = offscreenCanvas;
|
const canvas = offscreenCanvas;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.globalAlpha = 0.5;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const size = canvas.width;
|
const size = canvas.width;
|
||||||
@@ -336,7 +336,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
|
|
||||||
if (startPosSnapped) {
|
if (startPosSnapped) {
|
||||||
const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size);
|
const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size);
|
||||||
|
|
||||||
// Start Marker (Player Arrow/Dot)
|
// Start Marker (Player Arrow/Dot)
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
|
ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { MapControls, OrthographicCamera, useGLTF } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 1. Terrain Scene
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function TerrainScene() {
|
||||||
|
const { scene } = useGLTF('/models/terrain/terrain.glb');
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<ambientLight intensity={1.5} />
|
||||||
|
<directionalLight position={[10, 20, 10]} intensity={2} />
|
||||||
|
<primitive object={scene} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 2. Waypoint Overlay (Debug visualization)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function WaypointOverlay({ waypoints, visible }: { waypoints: any[], visible: boolean }) {
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{waypoints.map((w) => (
|
||||||
|
<mesh key={w.id} position={[w.x, w.y + 1, w.z]}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#10b981" />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 3. Camera Manager (Handles Orthographic Math & Downloads)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function CameraManager({
|
||||||
|
autoBounds,
|
||||||
|
boundsTextRef
|
||||||
|
}: {
|
||||||
|
autoBounds: any,
|
||||||
|
boundsTextRef: React.RefObject<HTMLPreElement>
|
||||||
|
}) {
|
||||||
|
const { camera, gl, scene } = useThree();
|
||||||
|
const controlsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Apply Auto-Bounds function
|
||||||
|
useEffect(() => {
|
||||||
|
const applyAutoBounds = () => {
|
||||||
|
if (camera instanceof THREE.OrthographicCamera && autoBounds) {
|
||||||
|
const width = autoBounds.maxX - autoBounds.minX;
|
||||||
|
const height = autoBounds.maxZ - autoBounds.minZ;
|
||||||
|
const centerX = (autoBounds.minX + autoBounds.maxX) / 2;
|
||||||
|
const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2;
|
||||||
|
|
||||||
|
camera.position.set(centerX, 200, centerZ);
|
||||||
|
camera.left = -width / 2;
|
||||||
|
camera.right = width / 2;
|
||||||
|
camera.top = height / 2;
|
||||||
|
camera.bottom = -height / 2;
|
||||||
|
camera.zoom = 1;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
if (controlsRef.current) {
|
||||||
|
controlsRef.current.target.set(centerX, 0, centerZ);
|
||||||
|
controlsRef.current.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).applyAutoBounds = applyAutoBounds;
|
||||||
|
// Initial apply
|
||||||
|
applyAutoBounds();
|
||||||
|
|
||||||
|
return () => { delete (window as any).applyAutoBounds; };
|
||||||
|
}, [camera, autoBounds]);
|
||||||
|
|
||||||
|
// Track dynamic bounds without triggering React re-renders!
|
||||||
|
useFrame(() => {
|
||||||
|
if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) {
|
||||||
|
const width = (camera.right - camera.left) / camera.zoom;
|
||||||
|
const height = (camera.top - camera.bottom) / camera.zoom;
|
||||||
|
const minX = Math.round(camera.position.x - width / 2);
|
||||||
|
const maxX = Math.round(camera.position.x + width / 2);
|
||||||
|
const minZ = Math.round(camera.position.z - height / 2);
|
||||||
|
const maxZ = Math.round(camera.position.z + height / 2);
|
||||||
|
|
||||||
|
// Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!)
|
||||||
|
boundsTextRef.current.innerText = JSON.stringify({ minX, maxX, minZ, maxZ }, null, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach screenshot capture logic
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).downloadMapScreenshot = () => {
|
||||||
|
// Force an immediate render frame to ensure no UI overlays are missing
|
||||||
|
gl.render(scene, camera);
|
||||||
|
const dataUrl = gl.domElement.toDataURL("image/png");
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = dataUrl;
|
||||||
|
a.download = "map_background.png";
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
return () => { delete (window as any).downloadMapScreenshot; };
|
||||||
|
}, [gl, camera, scene]);
|
||||||
|
|
||||||
|
return <MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 4. Main Page Route Component
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
export function BackgroundMapPage() {
|
||||||
|
const [waypoints, setWaypoints] = useState<any[]>([]);
|
||||||
|
const [showWaypoints, setShowWaypoints] = useState(true);
|
||||||
|
const boundsTextRef = useRef<HTMLPreElement>(null);
|
||||||
|
|
||||||
|
// Load road network waypoints to compute perfect GPS bounds
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('la-fabrik-waypoints');
|
||||||
|
if (saved) {
|
||||||
|
setWaypoints(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
fetch('/roadNetwork.json')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setWaypoints(data))
|
||||||
|
.catch(() => { });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compute exact bounds that the EbikeGPSMap will use by default
|
||||||
|
const autoBounds = useMemo(() => {
|
||||||
|
if (waypoints.length === 0) return null;
|
||||||
|
const xs = waypoints.map(w => w.x);
|
||||||
|
const zs = waypoints.map(w => w.z);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const minZ = Math.min(...zs);
|
||||||
|
const maxZ = Math.max(...zs);
|
||||||
|
|
||||||
|
// CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE.
|
||||||
|
// If the camera is rectangular, the exported PNG will be distorted when drawn
|
||||||
|
// on the EbikeGPSMap's 1024x1024 canvas!
|
||||||
|
const width = maxX - minX;
|
||||||
|
const height = maxZ - minZ;
|
||||||
|
const maxDim = Math.max(width, height);
|
||||||
|
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerZ = (minZ + maxZ) / 2;
|
||||||
|
|
||||||
|
const paddedDim = maxDim * 1.15 || 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: centerX - paddedDim / 2,
|
||||||
|
maxX: centerX + paddedDim / 2,
|
||||||
|
minZ: centerZ - paddedDim / 2,
|
||||||
|
maxZ: centerZ + paddedDim / 2,
|
||||||
|
};
|
||||||
|
}, [waypoints]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100vw', height: '100vh', background: '#050505', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
{/*
|
||||||
|
CRITICAL: The DOM element MUST be a perfect square so the resulting PNG
|
||||||
|
is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture!
|
||||||
|
*/}
|
||||||
|
<div style={{ width: 'min(100vw, 100vh)', height: 'min(100vw, 100vh)', background: '#000', position: 'relative' }}>
|
||||||
|
<Canvas gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}>
|
||||||
|
<OrthographicCamera makeDefault position={[0, 200, 0]} near={0.1} far={1000} />
|
||||||
|
<TerrainScene />
|
||||||
|
<WaypointOverlay waypoints={waypoints} visible={showWaypoints} />
|
||||||
|
<CameraManager autoBounds={autoBounds} boundsTextRef={boundsTextRef} />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Premium Glassmorphic UI Dashboard */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 24, left: 24,
|
||||||
|
background: 'rgba(15, 23, 42, 0.85)', padding: 24,
|
||||||
|
borderRadius: 16, border: '1px solid #334155',
|
||||||
|
color: 'white', fontFamily: 'system-ui, sans-serif',
|
||||||
|
backdropFilter: 'blur(12px)', width: 360,
|
||||||
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5)'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ margin: '0 0 16px 0', fontSize: '1.4rem', color: '#38bdf8' }}>GPS Map Generator</h2>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '0.9rem', color: '#94a3b8', marginBottom: 20, lineHeight: 1.5 }}>
|
||||||
|
1. Cadrez votre carte (ou utilisez le <b>Cadrage Automatique</b>).<br />
|
||||||
|
2. Masquez les waypoints (fond visuel seul).<br />
|
||||||
|
3. Cliquez sur <b>Capturer la carte</b>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWaypoints(!showWaypoints)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px', marginBottom: 12,
|
||||||
|
background: showWaypoints ? '#1e293b' : '#334155',
|
||||||
|
border: '1px solid #475569', color: 'white',
|
||||||
|
borderRadius: 8, cursor: 'pointer', fontWeight: 600,
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showWaypoints ? '👁️ Masquer Waypoints' : '👁️🗨️ Afficher Waypoints'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if ((window as any).applyAutoBounds) (window as any).applyAutoBounds();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px', marginBottom: 16,
|
||||||
|
background: '#1e293b', border: '1px solid #475569',
|
||||||
|
color: '#10b981', borderRadius: 8, cursor: 'pointer', fontWeight: 600,
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎯 Cadrage Automatique
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if ((window as any).downloadMapScreenshot) (window as any).downloadMapScreenshot();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '14px', background: '#0ea5e9',
|
||||||
|
border: 'none', color: 'white', borderRadius: 8,
|
||||||
|
cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(14, 165, 233, 0.4)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📸 Capturer la carte (.png)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, padding: 16, background: '#020617', borderRadius: 10, fontSize: '0.85rem' }}>
|
||||||
|
<div style={{ color: '#64748b', marginBottom: 8, fontWeight: 600 }}>Limites Actuelles (worldBounds):</div>
|
||||||
|
<pre ref={boundsTextRef} style={{ margin: 0, color: '#10b981', fontFamily: 'monospace' }}>
|
||||||
|
Calcul...
|
||||||
|
</pre>
|
||||||
|
<div style={{ color: '#ef4444', marginTop: 12, fontSize: '0.75rem', lineHeight: 1.4 }}>
|
||||||
|
*Si vous décadrez à la souris, vous devrez copier ces valeurs exactes dans la prop <code>worldBounds</code> de votre composant <b>EbikeGPSMap</b> !
|
||||||
|
<br /><br />
|
||||||
|
Astuce : Utilisez le <b>Cadrage Automatique</b> pour ne rien avoir à configurer.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { HomePage } from "@/pages/page";
|
import { HomePage } from "@/pages/page";
|
||||||
import { EditorPage } from "@/pages/editor/page";
|
import { EditorPage } from "@/pages/editor/page";
|
||||||
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
||||||
|
import { BackgroundMapPage } from "@/pages/backgroundmap/page";
|
||||||
import {
|
import {
|
||||||
DocsAnimationRoute,
|
DocsAnimationRoute,
|
||||||
DocsAudioRoute,
|
DocsAudioRoute,
|
||||||
@@ -50,6 +51,12 @@ const waypointRoute = createRoute({
|
|||||||
component: WaypointEditorPage,
|
component: WaypointEditorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const backgroundMapRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/backgroundmap",
|
||||||
|
component: BackgroundMapPage,
|
||||||
|
});
|
||||||
|
|
||||||
const docsRoute = createRoute({
|
const docsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/docs",
|
path: "/docs",
|
||||||
@@ -86,6 +93,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
indexRoute,
|
indexRoute,
|
||||||
editorRoute,
|
editorRoute,
|
||||||
waypointRoute,
|
waypointRoute,
|
||||||
|
backgroundMapRoute,
|
||||||
docsRoute.addChildren(docsChildRoutes),
|
docsRoute.addChildren(docsChildRoutes),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
{/* Render Pathfinder Maps Waypoints & Routes visually */}
|
{/* Render Pathfinder Maps Waypoints & Routes visually */}
|
||||||
<group name="pathfinder-maps-visuals">
|
<group name="pathfinder-maps-visuals">
|
||||||
{/* Render Connection Lines */}
|
{/* Render Connection Lines */}
|
||||||
{waypoints.flatMap((wp) =>
|
{waypoints.flatMap((wp) =>
|
||||||
wp.connections.map((connId) => {
|
wp.connections.map((connId) => {
|
||||||
const other = waypoints.find((w) => w.id === connId);
|
const other = waypoints.find((w) => w.id === connId);
|
||||||
// Draw each line only once by enforcing wp.id < other.id
|
// Draw each line only once by enforcing wp.id < other.id
|
||||||
@@ -174,7 +174,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
{waypoints.map((wp) => (
|
{waypoints.map((wp) => (
|
||||||
<mesh key={`wp-sphere-${wp.id}`} position={[wp.x, wp.y + 0.3, wp.z]}>
|
<mesh key={`wp-sphere-${wp.id}`} position={[wp.x, wp.y + 0.3, wp.z]}>
|
||||||
<sphereGeometry args={[0.35, 16, 16]} />
|
<sphereGeometry args={[0.35, 16, 16]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color="#059669" // Deep emerald green
|
color="#059669" // Deep emerald green
|
||||||
transparent
|
transparent
|
||||||
opacity={0.8}
|
opacity={0.8}
|
||||||
@@ -250,11 +250,18 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
</mesh>
|
</mesh>
|
||||||
{/* GPS Map screen plane */}
|
{/* GPS Map screen plane */}
|
||||||
<group position={[0, 0, 0.06]}>
|
<group position={[0, 0, 0.06]}>
|
||||||
<EbikeGPSMap
|
<EbikeGPSMap
|
||||||
width={4}
|
width={4}
|
||||||
height={4}
|
height={4}
|
||||||
startPos={{ x: 10, y: 0, z: -10 }}
|
startPos={{ x: 10, y: 0, z: -10 }}
|
||||||
destPos={{ x: -40, y: 0, z: 30 }}
|
destPos={{ x: -40, y: 0, z: 30 }}
|
||||||
|
mapImageUrl="/map_background.png"
|
||||||
|
worldBounds={{
|
||||||
|
minX: -146, // Les valeurs exactes
|
||||||
|
maxX: 131, // que l'outil t'aura
|
||||||
|
minZ: -139, // affiché en temps réel !
|
||||||
|
maxZ: 138
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
Reference in New Issue
Block a user