235 lines
7.7 KiB
TypeScript
235 lines
7.7 KiB
TypeScript
import { useEffect, useRef, useMemo } from "react";
|
||
import { useFrame } from "@react-three/fiber";
|
||
import { useTexture } from "@react-three/drei";
|
||
import * as THREE from "three";
|
||
import type { Vector3Tuple } from "@/types/three/three";
|
||
import "@/types/ebike/ebikeWindow";
|
||
|
||
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
|
||
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
|
||
|
||
export interface EbikeSpeedmeterProps {
|
||
width?: number;
|
||
height?: number;
|
||
/** Local position offset within the parent group. Default: [0, 0, 0] */
|
||
position?: Vector3Tuple;
|
||
/**
|
||
* Needle rotation.z when speedFactor = 0.
|
||
* Default: Math.PI / 2 (pointing left — 9 o'clock)
|
||
*/
|
||
minAngle?: number;
|
||
/**
|
||
* Needle rotation.z when speedFactor = 1.
|
||
* Default: -Math.PI / 2 (pointing right — 3 o'clock)
|
||
*/
|
||
maxAngle?: number;
|
||
renderOrder?: number;
|
||
/**
|
||
* Inner radius of the gauge-fill arc, as a fraction of the canvas half-width.
|
||
* Tune this to align the fill with the cadran.png track. Default: 0.33
|
||
*/
|
||
gaugeInnerR?: number;
|
||
/**
|
||
* Outer radius of the gauge-fill arc, as a fraction of the canvas half-width.
|
||
* Tune this to align the fill with the cadran.png track. Default: 0.445
|
||
*/
|
||
gaugeOuterR?: number;
|
||
/**
|
||
* Width of the gauge-fill plane. Defaults to `width` when omitted.
|
||
* Lets you resize the fill independently of the cadran/needle.
|
||
*/
|
||
gaugeWidth?: number;
|
||
/**
|
||
* Height of the gauge-fill plane. Defaults to `height` when omitted.
|
||
* Lets you resize the fill independently of the cadran/needle.
|
||
*/
|
||
gaugeHeight?: number;
|
||
/**
|
||
* Horizontal offset of the arc pivot from the canvas centre.
|
||
* Expressed as a fraction of the canvas size: -0.1 = shift 10 % to the left,
|
||
* +0.1 = shift 10 % to the right. Default: 0
|
||
*/
|
||
gaugeOffsetX?: number;
|
||
/**
|
||
* Vertical offset of the arc pivot from its default position.
|
||
* Expressed as a fraction of the canvas size: -0.1 = shift upward (toward top
|
||
* of the plane), +0.1 = shift downward. Default: 0
|
||
*/
|
||
gaugeOffsetY?: number;
|
||
}
|
||
|
||
// The needle pivot is always at -height*0.38 in local space,
|
||
// which is always 12 % from the bottom of the plane (UV y = 0.12).
|
||
// With Three.js flipY texture convention, canvas y = (1 - 0.12) * size = 0.88 * size.
|
||
const NEEDLE_PIVOT_UV_Y = 0.12; // fraction from bottom
|
||
|
||
export function EbikeSpeedmeter({
|
||
width = 0.8,
|
||
height = 0.8,
|
||
position = [0, 0, 0],
|
||
minAngle = Math.PI / 2,
|
||
maxAngle = -Math.PI / 2,
|
||
renderOrder = 1000,
|
||
gaugeInnerR = 0.33,
|
||
gaugeOuterR = 0.445,
|
||
gaugeWidth,
|
||
gaugeHeight,
|
||
gaugeOffsetX = 0,
|
||
gaugeOffsetY = 0,
|
||
}: EbikeSpeedmeterProps): React.JSX.Element {
|
||
// Fall back to the main dimensions when gauge-specific ones aren't provided
|
||
const fillW = gaugeWidth ?? width;
|
||
const fillH = gaugeHeight ?? height;
|
||
const needleGroupRef = useRef<THREE.Group>(null);
|
||
const speedFactorRef = useRef(0);
|
||
|
||
// ── Dial & needle textures ──────────────────────────────────────────────────
|
||
const [dialTexture, needleTexture] = useTexture([
|
||
SPEEDOMETER_DIAL_TEXTURE,
|
||
SPEEDOMETER_NEEDLE_TEXTURE,
|
||
]) as [THREE.Texture, THREE.Texture];
|
||
|
||
const needleWidth = width * 0.68;
|
||
const needleHeight = needleWidth / 2;
|
||
|
||
useEffect(() => {
|
||
[dialTexture, needleTexture].forEach((tex) => {
|
||
tex.colorSpace = THREE.SRGBColorSpace;
|
||
tex.needsUpdate = true;
|
||
});
|
||
}, [dialTexture, needleTexture]);
|
||
|
||
// ── Gauge-fill canvas ───────────────────────────────────────────────────────
|
||
const fillCanvas = useMemo(() => {
|
||
const c = document.createElement("canvas");
|
||
c.width = 256;
|
||
c.height = 256;
|
||
return c;
|
||
}, []);
|
||
|
||
const fillTexture = useMemo(() => {
|
||
const tex = new THREE.CanvasTexture(fillCanvas);
|
||
tex.format = THREE.RGBAFormat;
|
||
tex.minFilter = THREE.LinearFilter;
|
||
tex.magFilter = THREE.LinearFilter;
|
||
return tex;
|
||
}, [fillCanvas]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
fillTexture.dispose();
|
||
},
|
||
[fillTexture],
|
||
);
|
||
|
||
// ── Frame loop ──────────────────────────────────────────────────────────────
|
||
useFrame((_, delta) => {
|
||
// 1. Smooth speed factor
|
||
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
|
||
speedFactorRef.current = THREE.MathUtils.lerp(
|
||
speedFactorRef.current,
|
||
target,
|
||
Math.min(1, delta * 10),
|
||
);
|
||
|
||
// 2. Needle rotation
|
||
if (needleGroupRef.current) {
|
||
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
|
||
minAngle,
|
||
maxAngle,
|
||
speedFactorRef.current,
|
||
);
|
||
}
|
||
|
||
// 3. Draw gauge fill -------------------------------------------------------
|
||
const ctx = fillCanvas.getContext("2d", { alpha: true });
|
||
if (!ctx) return;
|
||
|
||
const size = fillCanvas.width;
|
||
ctx.clearRect(0, 0, size, size);
|
||
|
||
// Default centre: horizontal middle + needle-pivot height.
|
||
// gaugeOffsetX/Y shift the pivot so the arc aligns with cadran.png.
|
||
const cx = size * (0.5 + gaugeOffsetX);
|
||
const cy = size * (1 - NEEDLE_PIVOT_UV_Y + gaugeOffsetY); // default ≈ 0.88 × size
|
||
|
||
const outerR = size * gaugeOuterR;
|
||
const innerR = size * gaugeInnerR;
|
||
|
||
// Arc sweeps clockwise from π (left) to current needle angle
|
||
const arcStart = Math.PI;
|
||
const arcEnd = Math.PI + speedFactorRef.current * Math.PI;
|
||
|
||
if (speedFactorRef.current > 0.005) {
|
||
// Radial gradient using #3F67DD — slightly transparent at inner edge,
|
||
// fully solid at outer edge for a depth effect.
|
||
const radial = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
|
||
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
|
||
radial.addColorStop(0.7, "rgba(118, 152, 255, 0.95)"); // outer edge
|
||
|
||
// Annular sector shape (outer arc + inner arc reversed)
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, outerR, arcStart, arcEnd, false);
|
||
ctx.arc(cx, cy, innerR, arcEnd, arcStart, true);
|
||
ctx.closePath();
|
||
|
||
ctx.fillStyle = radial;
|
||
ctx.shadowBlur = 16;
|
||
ctx.shadowColor = "#3F67DD";
|
||
ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
fillTexture.needsUpdate = true;
|
||
});
|
||
|
||
return (
|
||
<group renderOrder={renderOrder} position={position}>
|
||
{/* Gauge fill — behind the cadran frame (size controlled by gaugeWidth/gaugeHeight) */}
|
||
<mesh renderOrder={renderOrder - 1} position={[0, 0, -0.001]}>
|
||
<planeGeometry args={[fillW, fillH]} />
|
||
<meshBasicMaterial
|
||
map={fillTexture}
|
||
transparent
|
||
depthTest={false}
|
||
depthWrite={false}
|
||
toneMapped={false}
|
||
side={THREE.DoubleSide}
|
||
/>
|
||
</mesh>
|
||
|
||
{/* Dial frame (cadran.png) */}
|
||
<mesh renderOrder={renderOrder}>
|
||
<planeGeometry args={[width, height]} />
|
||
<meshBasicMaterial
|
||
map={dialTexture}
|
||
transparent
|
||
depthTest={false}
|
||
depthWrite={false}
|
||
toneMapped={false}
|
||
side={THREE.DoubleSide}
|
||
/>
|
||
</mesh>
|
||
|
||
{/* Needle — pivot at bottom-centre of the arc */}
|
||
<group
|
||
ref={needleGroupRef}
|
||
position={[0, -height * 0.38, 0.002]}
|
||
rotation={[0, 0, 0]}
|
||
>
|
||
<mesh position={[0, needleHeight / 2, 0]} renderOrder={renderOrder + 1}>
|
||
<planeGeometry args={[needleWidth, needleHeight]} />
|
||
<meshBasicMaterial
|
||
map={needleTexture}
|
||
transparent
|
||
depthTest={false}
|
||
depthWrite={false}
|
||
toneMapped={false}
|
||
side={THREE.DoubleSide}
|
||
/>
|
||
</mesh>
|
||
</group>
|
||
</group>
|
||
);
|
||
}
|