Auto-install Lua, smooth all panels, airspace overlay + launcher region picker
FlyWithLua auto-install: bridge drops fms-sync/ui-sync/terrain-probe into X-Plane's FlyWithLua Scripts dir on startup and self-updates (content-compare). Graceful when no X-Plane / no FlyWithLua. /api/lua/install + status in health. Desktop app bundles the scripts and passes LUA_SRC_DIR to the sidecar. Smoothing: shared useEased/useEasedAngle hook (api/ease.js) with render-bail on settle. VFR steam gauges now interpolate to 60fps instead of stepping at the ~10Hz value stream. MFD ownship no longer vibrates — position/heading eased in a single rAF loop, follow-pan without animated-panTo pile-up (pauses on range zoom). Airspace overlay: server/airspace.js loads per-region GeoJSON, classifies (B/C/D/TMA/CTR/MOA/Restricted/Prohibited/Danger), bbox query, and downloads regions on demand — FAA (US, key-free) and OpenAIP (Europe, user key). New AIRSPACE softkey draws chart-coloured boundaries (B blue, C magenta, D dashed), non-interactive so map-clicks still drop waypoints. Launcher gains a "Lufträume" section to pick/download regions via the running bridge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
// Frame-rate-independent easing of a scalar toward a moving target (alpha from
|
||||
// dt + a time constant). The rAF loop runs continuously so the value keeps
|
||||
// gliding between the bridge's discrete value updates — turning a stuttery
|
||||
// ~10–20 Hz stream into a smooth 60 fps motion. To avoid needless React work it
|
||||
// only re-renders the consumer while the value is actually moving: once settled,
|
||||
// the functional setState returns the same number and React bails (Object.is).
|
||||
export function useEased(target, tau = 0.08) {
|
||||
const [, force] = useState(0);
|
||||
const cur = useRef(target), tg = useRef(target);
|
||||
tg.current = target;
|
||||
useEffect(() => {
|
||||
let raf, last = 0;
|
||||
const loop = (now) => {
|
||||
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
|
||||
const k = 1 - Math.exp(-dt / tau);
|
||||
const next = cur.current + (tg.current - cur.current) * k;
|
||||
const settled = Math.abs(tg.current - next) < 0.02;
|
||||
const val = settled ? tg.current : next;
|
||||
if (val !== cur.current) { cur.current = val; force((n) => (n + 1) & 0xffff); }
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [tau]);
|
||||
return cur.current;
|
||||
}
|
||||
|
||||
// As above but eases along the shortest arc across the 0↔360 wrap (headings).
|
||||
export function useEasedAngle(target, tau = 0.08) {
|
||||
const [, force] = useState(0);
|
||||
const cur = useRef(target), tg = useRef(target);
|
||||
tg.current = target;
|
||||
useEffect(() => {
|
||||
let raf, last = 0;
|
||||
const loop = (now) => {
|
||||
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
|
||||
const k = 1 - Math.exp(-dt / tau);
|
||||
const d = ((tg.current - cur.current + 540) % 360) - 180; // shortest signed arc
|
||||
const next = Math.abs(d) < 0.05 ? tg.current : cur.current + d * k;
|
||||
const val = ((next % 360) + 360) % 360;
|
||||
if (val !== cur.current) { cur.current = val; force((n) => (n + 1) & 0xffff); }
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [tau]);
|
||||
return cur.current;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ const PFD_MENU = {
|
||||
// extra layer in an otherwise-empty slot.)
|
||||
const MFD_MENU = {
|
||||
root: ['ENGINE', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
|
||||
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', '', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
|
||||
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', 'AIRSPACE', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
|
||||
engine: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
|
||||
};
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
||||
else if (label === 'OSM') setBase('osm');
|
||||
else if (label === 'DCLTR') cycleDcltr(onMapMode);
|
||||
else if (label === 'AIRWAYS') onMapMode && onMapMode((m) => ({ ...m, airways: !m.airways }));
|
||||
else if (label === 'AIRSPACE') onMapMode && onMapMode((m) => ({ ...m, airspace: !m.airspace }));
|
||||
} else {
|
||||
if (label === 'PFD') setPage('pfd');
|
||||
else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root');
|
||||
@@ -109,7 +110,8 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
||||
const isOn = (label) => {
|
||||
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|
||||
|| (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm')
|
||||
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways);
|
||||
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways)
|
||||
|| (label === 'AIRSPACE' && mapMode?.airspace);
|
||||
return (label === 'SYN TERR' && svt3d) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|
||||
|| (label === 'DME' && dme) || (label === 'OBS' && obs) || (label === 'CAUTION' && (alerts || hasAlerts))
|
||||
|| (label === 'STBY' && xpdrMode === 1) || (label === 'ON' && xpdrMode === 2) || (label === 'ALT' && xpdrMode === 3)
|
||||
|
||||
@@ -57,6 +57,22 @@ const TILES = {
|
||||
dark: null,
|
||||
};
|
||||
|
||||
// G1000 / aeronautical-chart airspace styling, keyed by our coarse class. B/C/D
|
||||
// follow chart convention (B solid blue, C solid magenta, D dashed blue);
|
||||
// special-use areas use warm hues. Class A/E are omitted (they blanket huge
|
||||
// areas and only clutter the moving map).
|
||||
const ASP_STYLE = {
|
||||
B: { color: '#2f7bff', weight: 1.8, dashArray: null },
|
||||
C: { color: '#e23bd0', weight: 1.7, dashArray: null },
|
||||
D: { color: '#4aa3ff', weight: 1.4, dashArray: '5 4' },
|
||||
TMA: { color: '#2f7bff', weight: 1.3, dashArray: null },
|
||||
CTR: { color: '#4aa3ff', weight: 1.4, dashArray: '5 4' },
|
||||
MOA: { color: '#ff8a3b', weight: 1.4, dashArray: '7 4' },
|
||||
RESTRICTED: { color: '#ff5a4a', weight: 1.5, dashArray: '7 4' },
|
||||
PROHIBITED: { color: '#ff3636', weight: 2, dashArray: null },
|
||||
DANGER: { color: '#ff5a4a', weight: 1.4, dashArray: '7 4' },
|
||||
};
|
||||
|
||||
export default function MapView({ values, flightPlan, fp, inset = false, hud = true, mapMode, dcltr = 0, onView, rangeNm, terrain, rose = false }) {
|
||||
const elRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
@@ -69,8 +85,12 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
const awyLayerRef = useRef(null);
|
||||
const awyOnRef = useRef(false);
|
||||
const refreshAirwaysRef = useRef(null);
|
||||
const aspLayerRef = useRef(null);
|
||||
const aspOnRef = useRef(false);
|
||||
const refreshAirspaceRef = useRef(null);
|
||||
const baseRef = useRef(null);
|
||||
const terrRef = useRef(null);
|
||||
const zoomingRef = useRef(false);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const followRef = useRef(true);
|
||||
followRef.current = follow;
|
||||
@@ -81,6 +101,8 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
const gs = num(values.groundspeed) * 1.94384; // m/s -> kt
|
||||
const base = mapMode?.base || 'topo';
|
||||
const airways = !!mapMode?.airways;
|
||||
const airspace = !!mapMode?.airspace;
|
||||
aspOnRef.current = airspace;
|
||||
const dcltrRef = useRef(dcltr);
|
||||
dcltrRef.current = dcltr;
|
||||
awyOnRef.current = airways;
|
||||
@@ -113,7 +135,8 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
map.getPane('terrain').style.zIndex = 250;
|
||||
map.getPane('terrain').style.pointerEvents = 'none';
|
||||
|
||||
awyLayerRef.current = L.layerGroup().addTo(map); // airways (under everything else)
|
||||
aspLayerRef.current = L.layerGroup().addTo(map); // airspace polygons (bottom overlay)
|
||||
awyLayerRef.current = L.layerGroup().addTo(map); // airways
|
||||
navLayerRef.current = L.layerGroup().addTo(map); // real airports/navaids/fixes
|
||||
routeRef.current = L.layerGroup().addTo(map); // flight-plan legs (white + magenta active)
|
||||
wpLayerRef.current = L.layerGroup().addTo(map);
|
||||
@@ -146,6 +169,31 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
};
|
||||
refreshAirwaysRef.current = refreshAirways;
|
||||
|
||||
// AIRSPACE overlay (Class B/C/D + special-use from installed region GeoJSON;
|
||||
// see server/airspace.js). Boundaries are coloured by class with a faint
|
||||
// fill; non-interactive so map-clicks still drop waypoints.
|
||||
const refreshAirspace = async () => {
|
||||
const layer = aspLayerRef.current;
|
||||
if (!layer) return;
|
||||
if (!aspOnRef.current || map.getZoom() < 6) { layer.clearLayers(); return; }
|
||||
const b = map.getBounds();
|
||||
try {
|
||||
const res = await fetch(`/api/airspace/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&limit=400`);
|
||||
if (!res.ok) return;
|
||||
const feats = await res.json();
|
||||
layer.clearLayers();
|
||||
for (const f of feats) {
|
||||
const st = ASP_STYLE[f.cls];
|
||||
if (!st) continue; // skip A/E/OTHER — too broad to be useful
|
||||
L.geoJSON(f.geometry, {
|
||||
interactive: false,
|
||||
style: { color: st.color, weight: st.weight, opacity: 0.85, dashArray: st.dashArray, fill: true, fillColor: st.color, fillOpacity: 0.05 },
|
||||
}).addTo(layer);
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
};
|
||||
refreshAirspaceRef.current = refreshAirspace;
|
||||
|
||||
// Pull X-Plane's own nav data for the current view and draw it as G1000-style
|
||||
// vector symbology (cyan airports, green VOR hexagons, NDB dot-rings, fixes).
|
||||
const refreshNav = async () => {
|
||||
@@ -170,9 +218,10 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
for (const f of feats) navSymbol(f, labels).addTo(layer);
|
||||
} catch { /* aborted or offline — leave as is */ }
|
||||
};
|
||||
map.on('moveend', () => { refreshNav(); refreshAirways(); reportView(map); });
|
||||
map.on('zoomend', () => reportView(map));
|
||||
setTimeout(() => { refreshNav(); refreshAirways(); reportView(map); }, 300);
|
||||
map.on('moveend', () => { refreshNav(); refreshAirways(); refreshAirspace(); reportView(map); });
|
||||
map.on('zoomstart', () => { zoomingRef.current = true; });
|
||||
map.on('zoomend', () => { zoomingRef.current = false; reportView(map); });
|
||||
setTimeout(() => { refreshNav(); refreshAirways(); refreshAirspace(); reportView(map); }, 300);
|
||||
|
||||
// compass rose anchored to the aircraft (north-up) — tracks the ownship
|
||||
if (rose) {
|
||||
@@ -202,6 +251,8 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
|
||||
// redraw airways when the AIRWAYS toggle changes
|
||||
useEffect(() => { refreshAirwaysRef.current && refreshAirwaysRef.current(); }, [airways]); // eslint-disable-line
|
||||
// redraw airspace when the AIRSPACE toggle changes
|
||||
useEffect(() => { refreshAirspaceRef.current && refreshAirspaceRef.current(); }, [airspace]); // eslint-disable-line
|
||||
|
||||
// TERRAIN AWARENESS overlay: colour the elevation grid (from the FlyWithLua
|
||||
// terrain probe) relative to aircraft altitude — red within 100 ft below/above,
|
||||
@@ -260,16 +311,39 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
else map.fire('moveend'); // triggers refreshNav to redraw symbols
|
||||
}, [dcltr]); // eslint-disable-line
|
||||
|
||||
// update aircraft position + heading
|
||||
// Smooth ownship motion. The sim streams position/heading at ~10 Hz; setting
|
||||
// the marker straight from that (and firing an animated panTo on every update)
|
||||
// makes the aircraft vibrate and the animations fight each other. Instead we
|
||||
// hold the latest values as a target and ease toward it in a single rAF loop,
|
||||
// applying the result imperatively (no React re-render per frame) — so the
|
||||
// aircraft glides at 60 fps and the map follows without jitter.
|
||||
const tgtRef = useRef({ lat, lon, track });
|
||||
const curRef = useRef({ lat, lon, track });
|
||||
tgtRef.current = { lat, lon, track };
|
||||
useEffect(() => {
|
||||
const ac = acRef.current, map = mapRef.current;
|
||||
if (!ac || !map) return;
|
||||
ac.setLatLng([lat, lon]);
|
||||
roseRef.current?.setLatLng([lat, lon]);
|
||||
const el = ac.getElement()?.querySelector('svg');
|
||||
if (el) el.style.transform = `rotate(${track}deg)`;
|
||||
if (followRef.current) map.panTo([lat, lon], { animate: true, duration: 0.5 });
|
||||
}, [lat, lon, track]);
|
||||
let raf, last = 0;
|
||||
const loop = (now) => {
|
||||
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
|
||||
const kp = 1 - Math.exp(-dt / 0.22); // position time-constant (gentle)
|
||||
const kt = 1 - Math.exp(-dt / 0.14); // heading time-constant
|
||||
const c = curRef.current, t = tgtRef.current;
|
||||
c.lat += (t.lat - c.lat) * kp;
|
||||
c.lon += (t.lon - c.lon) * kp;
|
||||
const d = ((t.track - c.track + 540) % 360) - 180; // shortest arc
|
||||
c.track += d * kt;
|
||||
const ac = acRef.current, map = mapRef.current;
|
||||
if (ac && map) {
|
||||
ac.setLatLng([c.lat, c.lon]);
|
||||
roseRef.current?.setLatLng([c.lat, c.lon]);
|
||||
const el = ac.getElement()?.querySelector('svg');
|
||||
if (el) el.style.transform = `rotate(${((c.track % 360) + 360) % 360}deg)`;
|
||||
if (followRef.current && !zoomingRef.current) map.panTo([c.lat, c.lon], { animate: false });
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// redraw route + waypoints when the plan changes. Like the real G1000, the
|
||||
// active leg (to waypoint `activeLeg`) is magenta; all other legs are white.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect, Suspense, lazy } from 'react';
|
||||
import { num, systemAlerts } from '../api/useXplane.js';
|
||||
import { useEased, useEasedAngle } from '../api/ease.js';
|
||||
import MapView from './MapView.jsx';
|
||||
import Nearest from './Nearest.jsx';
|
||||
import TimerRef from './TimerRef.jsx';
|
||||
@@ -99,50 +100,6 @@ const SVT_BOX = { x: 0, y: 74, w: W, h: H - 74 };
|
||||
// The INSET moving map sits in the bottom-left corner (toggled by INSET softkey).
|
||||
const INSET_BOX = { x: 6, y: 556, w: 300, h: 172 };
|
||||
|
||||
// Frame-rate-independent easing of a scalar toward a moving target (alpha from
|
||||
// dt + a time constant). Re-renders the consumer only while it's moving —
|
||||
// setState bails out when the value has settled. Used to glide the speed/alt
|
||||
// tapes and the heading rose, just like the imperative attitude smoothing.
|
||||
function useEased(target, tau = 0.08) {
|
||||
const [v, setV] = useState(target);
|
||||
const cur = useRef(target), tg = useRef(target);
|
||||
tg.current = target;
|
||||
useEffect(() => {
|
||||
let raf, last = 0;
|
||||
const loop = (now) => {
|
||||
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
|
||||
const k = 1 - Math.exp(-dt / tau);
|
||||
const next = cur.current + (tg.current - cur.current) * k;
|
||||
cur.current = Math.abs(tg.current - next) < 0.02 ? tg.current : next;
|
||||
setV(cur.current);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [tau]);
|
||||
return v;
|
||||
}
|
||||
// As above but eases along the shortest arc across the 0↔360 wrap (headings).
|
||||
function useEasedAngle(target, tau = 0.08) {
|
||||
const [v, setV] = useState(target);
|
||||
const cur = useRef(target), tg = useRef(target);
|
||||
tg.current = target;
|
||||
useEffect(() => {
|
||||
let raf, last = 0;
|
||||
const loop = (now) => {
|
||||
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
|
||||
const k = 1 - Math.exp(-dt / tau);
|
||||
const d = ((tg.current - cur.current + 540) % 360) - 180; // shortest signed arc
|
||||
cur.current = Math.abs(d) < 0.05 ? tg.current : cur.current + d * k;
|
||||
setV(((cur.current % 360) + 360) % 360);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [tau]);
|
||||
return v;
|
||||
}
|
||||
|
||||
export default function PFD({ values: V, command, connected = true, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, dme = false, onCloseDme, alerts = false, onCloseAlerts, baroHpa = false, obs = false, minimums, onMinimums, flightPlan, fp }) {
|
||||
const wrapRef = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { num } from '../api/useXplane.js';
|
||||
import { useEased, useEasedAngle } from '../api/ease.js';
|
||||
import KAP140 from './KAP140.jsx';
|
||||
|
||||
// Classic analog "six-pack" VFR panel: airspeed, attitude, altimeter, turn
|
||||
@@ -55,7 +56,7 @@ function ticks(min, max, a0, a1, step, big = 1, r = 84, lab) {
|
||||
|
||||
/* ---------- Airspeed ---------- */
|
||||
function ASI({ V }) {
|
||||
const kt = num(V.airspeed);
|
||||
const kt = useEased(num(V.airspeed));
|
||||
const A0 = -150, A1 = 150, MIN = 0, MAX = 200;
|
||||
const ang = A0 + (clamp(kt, MIN, MAX) - MIN) / (MAX - MIN) * (A1 - A0);
|
||||
const arc = (lo, hi, color, rr, wdt) => {
|
||||
@@ -80,7 +81,7 @@ function ASI({ V }) {
|
||||
|
||||
/* ---------- Attitude ---------- */
|
||||
function AI({ V }) {
|
||||
const pitch = num(V.pitch), roll = num(V.roll);
|
||||
const pitch = useEased(num(V.pitch)), roll = useEased(num(V.roll));
|
||||
const PPD = 2.0; // px per degree pitch
|
||||
const off = clamp(pitch, -25, 25) * PPD;
|
||||
return (
|
||||
@@ -118,7 +119,7 @@ function AI({ V }) {
|
||||
|
||||
/* ---------- Altimeter (3-pointer) ---------- */
|
||||
function ALT({ V }) {
|
||||
const alt = num(V.altitude), baro = num(V.baro, 29.92);
|
||||
const alt = useEased(num(V.altitude)), baro = num(V.baro, 29.92);
|
||||
const a100 = (alt % 1000) / 1000 * 360;
|
||||
const a1000 = (alt % 10000) / 10000 * 360;
|
||||
const a10000 = (alt % 100000) / 100000 * 360;
|
||||
@@ -141,7 +142,7 @@ function ALT({ V }) {
|
||||
|
||||
/* ---------- Turn coordinator ---------- */
|
||||
function TC({ V }) {
|
||||
const roll = num(V.roll), slip = num(V.slip);
|
||||
const roll = useEased(num(V.roll)), slip = useEased(num(V.slip));
|
||||
const bank = clamp(roll, -30, 30); // little-plane bank (approx turn rate)
|
||||
const ballX = 100 + clamp(slip, -8, 8) * 3.0;
|
||||
return (
|
||||
@@ -167,7 +168,7 @@ function TC({ V }) {
|
||||
|
||||
/* ---------- Heading indicator ---------- */
|
||||
function HI({ V }) {
|
||||
const hdg = ((num(V.heading) % 360) + 360) % 360;
|
||||
const hdg = useEasedAngle(((num(V.heading) % 360) + 360) % 360);
|
||||
const card = [];
|
||||
for (let d = 0; d < 360; d += 5) {
|
||||
const big = d % 30 === 0;
|
||||
@@ -193,7 +194,7 @@ function HI({ V }) {
|
||||
|
||||
/* ---------- Vertical speed ---------- */
|
||||
function VSI({ V }) {
|
||||
const vs = clamp(num(V.vspeed), -2000, 2000);
|
||||
const vs = clamp(useEased(num(V.vspeed), 0.12), -2000, 2000);
|
||||
// 0 at 9 o'clock (270°), climb sweeps up (toward 0/up), descent down.
|
||||
const ang = 270 + (vs / 2000) * 160; // -2000→110°, 0→270°, +2000→430°(=70°)
|
||||
return (
|
||||
@@ -233,7 +234,8 @@ function Dual({ title, l, r }) {
|
||||
const [x2, y2] = sp(60, 60, 46, sg(hi, min, max, a0, a1));
|
||||
return <path d={`M${x1} ${y1} A46 46 0 0 1 ${x2} ${y2}`} fill="none" stroke={color} strokeWidth="3" />;
|
||||
};
|
||||
const La = sg(l.value, l.min, l.max, -150, -20), Ra = sg(r.value, r.min, r.max, 20, 150);
|
||||
const lv = useEased(l.value, 0.18), rv = useEased(r.value, 0.18);
|
||||
const La = sg(lv, l.min, l.max, -150, -20), Ra = sg(rv, r.min, r.max, 20, 150);
|
||||
return (
|
||||
<SmallBezel title={title}>
|
||||
{l.green && band(l.green[0], l.green[1], l.min, l.max, -150, -20, '#21d04a')}
|
||||
@@ -249,7 +251,7 @@ function Dual({ title, l, r }) {
|
||||
|
||||
/* ---------- tachometer ---------- */
|
||||
function Tach({ V }) {
|
||||
const rpm = arr0(V.engRpm);
|
||||
const rpm = useEased(arr0(V.engRpm));
|
||||
const A0 = -150, A1 = 150;
|
||||
const ang = A0 + clamp(rpm, 0, 3500) / 3500 * (A1 - A0);
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user