claude hat das pfd sehr krass ueberarbeitet

This commit is contained in:
itsrubberduck
2026-02-21 18:04:37 +01:00
parent 9e2138d1ce
commit f5509d9fce
8 changed files with 655 additions and 487 deletions

View File

@@ -4,22 +4,25 @@ const props = withDefaults(defineProps<{
width?: number
height?: number
}>(), {
width: 80,
height: 300,
width: 110,
height: 325,
})
const uid = useId()
const clipId = computed(() => `alt-clip-${uid}`)
const readoutClipId = computed(() => `alt-readout-${uid}`)
const centerY = computed(() => props.height / 2)
const pixelsPerFoot = 0.15
// licarth: oneLfInPx = 28.5 at height=325 for FL (100ft) marks
const pxPerHundredFt = computed(() => props.height * 28.5 / 325)
function altToY(alt: number): number {
return centerY.value + (props.altitude - alt) * pixelsPerFoot
return centerY.value + (props.altitude - alt) * pxPerHundredFt.value / 100
}
const visibleRange = computed(() => {
const halfVisible = props.height / 2 / pixelsPerFoot + 200
const halfVisible = props.height / 2 / pxPerHundredFt.value * 100 + 200
return {
min: Math.floor((props.altitude - halfVisible) / 100) * 100,
max: Math.ceil((props.altitude + halfVisible) / 100) * 100,
@@ -27,23 +30,64 @@ const visibleRange = computed(() => {
})
const marks = computed(() => {
const result: Array<{ alt: number; y: number; isLabel: boolean }> = []
const result: Array<{ alt: number; y: number; isLabel: boolean; label: string }> = []
for (let alt = visibleRange.value.min; alt <= visibleRange.value.max; alt += 100) {
const y = altToY(alt)
const isLabel = alt % 500 === 0
result.push({ alt, y, isLabel })
const fl = Math.round(alt / 100)
const label = isLabel ? String(fl).padStart(3, '0') : ''
result.push({ alt, y, isLabel, label })
}
return result
})
const readoutText = computed(() => Math.round(props.altitude).toString())
// --- Readout dimensions ---
const readoutH = computed(() => Math.max(28, props.height * 0.11))
// Big digits box starts right after the tape ticks
const tapeTickW = 10
const readoutX = computed(() => tapeTickW + 4)
// Big digits: occupies ~55% of remaining width
const readoutAvailW = computed(() => props.width - readoutX.value - 2)
const bigBoxW = computed(() => readoutAvailW.value * 0.6)
const smallBoxW = computed(() => readoutAvailW.value * 0.4)
const readoutBoxHeight = 24
const readoutBoxWidth = computed(() => props.width - 8)
const tapeInnerX = 2
const tapeInnerY = 2
const tapeInnerWidth = computed(() => props.width - 4)
const tapeInnerHeight = computed(() => props.height - 4)
// Font sizes: make sure digits fit
const bigFontSize = computed(() => Math.max(12, Math.min(28, bigBoxW.value / 3.2)))
const smallFontSize = computed(() => Math.max(10, Math.min(20, smallBoxW.value / 2.4)))
const labelFontSize = computed(() => Math.max(10, Math.min(20, props.width * 0.16)))
// Drum roller: display altitude as separate digit groups
// Big digits: 10000s, 1000s, 100s
const bigDigits = computed(() => {
const alt = Math.max(0, props.altitude)
const d1 = Math.floor(alt / 10000)
const d2 = Math.floor((alt % 10000) / 1000)
const d3 = Math.floor((alt % 1000) / 100)
let d1val = d1
let d2val = d2
let d3val = d3
const lastTwo = alt % 100
if (lastTwo > 80) {
const offset = (lastTwo - 80) / 20
d3val += offset
if (alt % 1000 > 980) d2val += offset
if (alt % 10000 > 9980) d1val += offset
}
return { d1: d1val, d2: d2val, d3: d3val }
})
// Small digits: 00,20,40,60,80 drum
const smallDigitOffset = computed(() => {
return (props.altitude % 100) / 20
})
// Digit spacing for the big drum — tighter horizontal packing
const bigDigitSpacing = computed(() => bigBoxW.value / 3.5)
// Readout clip
const readoutClipY = computed(() => centerY.value - readoutH.value / 2)
const readoutClipH = computed(() => readoutH.value)
</script>
<template>
@@ -57,75 +101,120 @@ const tapeInnerHeight = computed(() => props.height - 4)
<clipPath :id="clipId">
<rect x="0" y="0" :width="width" :height="height" />
</clipPath>
<clipPath :id="readoutClipId">
<rect
:x="readoutX - 1"
:y="readoutClipY"
:width="bigBoxW + smallBoxW + 6"
:height="readoutClipH"
/>
</clipPath>
<radialGradient :id="`alt-bg-${uid}`" cx="0" cy="0.5" r="1.2">
<stop offset="0%" stop-color="#1f2a2c" />
<stop offset="57%" stop-color="#304c50" />
<stop offset="100%" stop-color="#1d282a" />
</radialGradient>
</defs>
<!-- Airbus-style tape body -->
<rect
x="0"
y="0"
:width="width"
:height="height"
fill="#1a2628"
rx="1"
/>
<rect
:x="tapeInnerX"
:y="tapeInnerY"
:width="tapeInnerWidth"
:height="tapeInnerHeight"
fill="#1d282a"
stroke="#304c50"
stroke-width="0.8"
/>
<!-- Background -->
<rect x="0" y="0" :width="width" :height="height" :fill="`url(#alt-bg-${uid})`" />
<!-- Scrolling tape (clipped) -->
<g :clip-path="`url(#${clipId})`">
<!-- Vertical reference line (left side) -->
<line :x1="tapeTickW" y1="0" :x2="tapeTickW" :y2="height" stroke="white" stroke-width="1" />
<g v-for="mark in marks" :key="mark.alt">
<!-- Tick mark (left side, toward attitude indicator) -->
<line
x1="1"
:y1="mark.y"
x2="10"
:y2="mark.y"
stroke="#f4f6fb"
stroke-width="1"
/>
<!-- Label (right of ticks) -->
<!-- Tick mark (left side) -->
<line x1="0" :y1="mark.y" :x2="tapeTickW" :y2="mark.y" stroke="white" stroke-width="1.5" />
<!-- Label (only every 500ft) -->
<text
v-if="mark.isLabel"
x="14"
:y="mark.y + 4"
fill="#f4f6fb"
font-size="12"
:x="tapeTickW + 4"
:y="mark.y + labelFontSize * 0.35"
fill="white"
:font-size="labelFontSize"
text-anchor="start"
font-family="monospace"
>
{{ Math.round(mark.alt / 100) }}
{{ mark.label }}
</text>
</g>
</g>
<!-- Current altitude readout box -->
<!-- Big digits box (100s, 1000s, 10000s) yellow top+bottom border, black fill -->
<rect
:x="(width - readoutBoxWidth) / 2"
:y="centerY - readoutBoxHeight / 2"
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="#02040b"
stroke="#fbe044"
stroke-width="1.4"
rx="1.5"
:x="readoutX"
:y="centerY - readoutH / 2"
:width="bigBoxW"
:height="readoutH"
fill="black"
/>
<line
:x1="readoutX" :y1="centerY - readoutH / 2"
:x2="readoutX + bigBoxW" :y2="centerY - readoutH / 2"
stroke="#fbe044" stroke-width="2"
/>
<line
:x1="readoutX" :y1="centerY + readoutH / 2"
:x2="readoutX + bigBoxW" :y2="centerY + readoutH / 2"
stroke="#fbe044" stroke-width="2"
/>
<!-- Big digit drum rollers (clipped) -->
<g :clip-path="`url(#${readoutClipId})`">
<!-- Digit 1 (ten-thousands) -->
<g :transform="`translate(${readoutX + bigDigitSpacing * 0.2}, ${centerY + bigFontSize * 0.35 - bigDigits.d1 * bigFontSize * 1.03})`">
<text
v-for="d in 10" :key="`d1-${d}`"
x="0" :y="(d - 1) * bigFontSize * 1.03"
fill="#3ae061" :font-size="bigFontSize" font-weight="bold" font-family="monospace"
>{{ d - 1 }}</text>
</g>
<!-- Digit 2 (thousands) -->
<g :transform="`translate(${readoutX + bigDigitSpacing * 1.1}, ${centerY + bigFontSize * 0.35 - bigDigits.d2 * bigFontSize * 1.03})`">
<text
v-for="d in 10" :key="`d2-${d}`"
x="0" :y="(d - 1) * bigFontSize * 1.03"
fill="#3ae061" :font-size="bigFontSize" font-weight="bold" font-family="monospace"
>{{ d - 1 }}</text>
</g>
<!-- Digit 3 (hundreds) -->
<g :transform="`translate(${readoutX + bigDigitSpacing * 2.0}, ${centerY + bigFontSize * 0.35 - bigDigits.d3 * bigFontSize * 1.03})`">
<text
v-for="d in 10" :key="`d3-${d}`"
x="0" :y="(d - 1) * bigFontSize * 1.03"
fill="#3ae061" :font-size="bigFontSize" font-weight="bold" font-family="monospace"
>{{ d - 1 }}</text>
</g>
</g>
<!-- Small digits box (tens: 00,20,40,60,80) full yellow border -->
<rect
:x="readoutX + bigBoxW"
:y="centerY - readoutH / 2 - 4"
:width="smallBoxW"
:height="readoutH + 8"
fill="black"
stroke="#fbe044"
stroke-width="2"
/>
<!-- Small digit roller (clipped) -->
<g :clip-path="`url(#${readoutClipId})`">
<g :transform="`translate(${readoutX + bigBoxW + smallBoxW * 0.1}, ${centerY + smallFontSize * 0.35 - smallDigitOffset * smallFontSize * 1.05})`">
<text
v-for="(val, i) in ['20', '00', '80', '60', '40', '20', '00', '80']"
:key="`sm-${i}`"
x="0" :y="i * smallFontSize * 1.05"
fill="#3ae061" :font-size="smallFontSize" font-weight="bold" font-family="monospace"
>{{ val }}</text>
</g>
</g>
<!-- Yellow pointer triangle (left of readout, pointing right) -->
<polygon
:points="`${readoutX - 5},${centerY - 6} ${readoutX},${centerY} ${readoutX - 5},${centerY + 6}`"
fill="#fbe044"
/>
<text
:x="width / 2"
:y="centerY + 5"
fill="#3ae061"
font-size="14"
font-weight="bold"
text-anchor="middle"
font-family="monospace"
>
{{ readoutText }}
</text>
</svg>
</template>

View File

@@ -4,58 +4,73 @@ const props = withDefaults(defineProps<{
bankAngle: number
size?: number
}>(), {
size: 300,
size: 290,
})
const uid = useId()
const cx = computed(() => props.size / 2)
const cy = computed(() => props.size / 2)
const pitchScale = computed(() => props.size / 60)
// licarth: oneDegreeInPixels = 6.7 at full size
const pitchScale = computed(() => props.size / 43.3)
const clipId = computed(() => `att-clip-${uid}`)
const skyGradId = computed(() => `att-sky-${uid}`)
const groundGradId = computed(() => `att-ground-${uid}`)
const WHITE = '#f4f6fb'
const YELLOW = '#faf56c'
const WHITE = '#fff'
const YELLOW = 'yellow'
// Pitch ladder marks matching licarth pattern
const pitchMarks = computed(() => {
const marks: Array<{ deg: number; y: number; isLabel: boolean; width: number }> = []
for (let deg = -30; deg <= 30; deg += 5) {
const marks: Array<{ deg: number; y: number; type: 'S' | 'M' | 'L'; halfWidth: number }> = []
for (let deg = -80; deg <= 80; deg += 2.5) {
if (deg === 0) continue
const y = -deg * pitchScale.value
const isLabel = deg % 10 === 0
const width = isLabel ? props.size * 0.2 : props.size * 0.1
marks.push({ deg, y, isLabel, width })
if (deg % 10 === 0) {
marks.push({ deg, y, type: 'L', halfWidth: props.size * 0.12 })
} else if (deg % 5 === 0) {
marks.push({ deg, y, type: 'M', halfWidth: props.size * 0.066 })
} else {
marks.push({ deg, y, type: 'S', halfWidth: props.size * 0.035 })
}
}
return marks
})
// Bank angle ticks
const bankAngles = [10, 20, 30, 45, 60]
const bankTicks = computed(() => {
const angles = [10, 20, 30, 45, 60]
const ticks: Array<{ angle: number; length: number }> = []
const r = props.size * 0.42
for (const a of angles) {
const len = a === 30 || a === 60 ? 12 : 8
ticks.push({ angle: -a, length: len })
ticks.push({ angle: a, length: len })
const r = props.size * 0.44
const ticks: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
for (const a of bankAngles) {
for (const sign of [-1, 1]) {
const angle = sign * a
const len = (a === 30 || a === 60) ? 12 : 8
const rad = ((angle - 90) * Math.PI) / 180
ticks.push({
x1: cx.value + r * Math.cos(rad),
y1: cy.value + r * Math.sin(rad),
x2: cx.value + (r - len) * Math.cos(rad),
y2: cy.value + (r - len) * Math.sin(rad),
})
}
}
return ticks.map(t => {
const rad = ((t.angle - 90) * Math.PI) / 180
const x1 = cx.value + r * Math.cos(rad)
const y1 = cy.value + r * Math.sin(rad)
const x2 = cx.value + (r - t.length) * Math.cos(rad)
const y2 = cy.value + (r - t.length) * Math.sin(rad)
return { x1, y1, x2, y2 }
})
return ticks
})
const bankPointer = computed(() => {
const r = props.size * 0.42
const s = 8
const tipX = cx.value
const r = props.size * 0.44
const s = 7
const tipY = cy.value - r
return `${tipX},${tipY} ${tipX - s},${tipY - s * 1.5} ${tipX + s},${tipY - s * 1.5}`
return `${cx.value},${tipY} ${cx.value - s},${tipY - s * 1.5} ${cx.value + s},${tipY - s * 1.5}`
})
const zeroRef = computed(() => {
const r = props.size * 0.44
const tipY = cy.value - r
return `${cx.value},${tipY} ${cx.value - 6},${tipY - 10} ${cx.value + 6},${tipY - 10}`
})
const horizonTransform = computed(() => {
@@ -67,9 +82,23 @@ const bankPointerTransform = computed(() => {
return `rotate(${-props.bankAngle}, ${cx.value}, ${cy.value})`
})
const wingSpan = computed(() => props.size * 0.18)
const wingThickness = computed(() => 3)
const dotRadius = computed(() => 4)
// Aircraft symbol (licarth-style L-shaped wings)
// licarth: wings from ±75 to ±133, tip drops to y=22, center square 10x10
const rightWingPoints = computed(() => {
const wi = props.size * 0.259
const wo = props.size * 0.459
const td = props.size * 0.076
const c = cy.value
return `${cx.value + wi},${c - 5} ${cx.value + wo},${c - 5} ${cx.value + wo},${c + 5} ${cx.value + wi + 10},${c + 5} ${cx.value + wi + 10},${c + td} ${cx.value + wi},${c + td}`
})
const leftWingPoints = computed(() => {
const wi = props.size * 0.259
const wo = props.size * 0.459
const td = props.size * 0.076
const c = cy.value
return `${cx.value - wi},${c - 5} ${cx.value - wo},${c - 5} ${cx.value - wo},${c + 5} ${cx.value - wi - 10},${c + 5} ${cx.value - wi - 10},${c + td} ${cx.value - wi},${c + td}`
})
</script>
<template>
@@ -95,7 +124,6 @@ const dotRadius = computed(() => 4)
<!-- Clipped viewport -->
<g :clip-path="`url(#${clipId})`">
<!-- Rotating group for pitch + bank -->
<g :transform="horizonTransform">
<!-- Sky -->
<rect
@@ -120,36 +148,37 @@ const dotRadius = computed(() => 4)
:x2="size * 2"
:y2="0"
:stroke="WHITE"
stroke-width="2"
stroke-width="2.5"
/>
<!-- Pitch ladder -->
<g v-for="mark in pitchMarks" :key="mark.deg">
<line
:x1="-mark.width / 2"
:x1="-mark.halfWidth"
:y1="mark.y"
:x2="mark.width / 2"
:x2="mark.halfWidth"
:y2="mark.y"
:stroke="WHITE"
:stroke-width="mark.isLabel ? 1.5 : 1"
:stroke-width="mark.type === 'L' ? 2.5 : mark.type === 'M' ? 2 : 1.5"
stroke-linecap="round"
/>
<template v-if="mark.isLabel">
<template v-if="mark.type === 'L'">
<text
:x="-mark.width / 2 - 6"
:y="mark.y + 4"
:x="-mark.halfWidth - 28"
:y="mark.y + 5"
:fill="WHITE"
font-size="11"
text-anchor="end"
font-size="13"
text-anchor="middle"
font-family="monospace"
>
{{ Math.abs(mark.deg) }}
</text>
<text
:x="mark.width / 2 + 6"
:y="mark.y + 4"
:x="mark.halfWidth + 28"
:y="mark.y + 5"
:fill="WHITE"
font-size="11"
text-anchor="start"
font-size="13"
text-anchor="middle"
font-family="monospace"
>
{{ Math.abs(mark.deg) }}
@@ -171,66 +200,38 @@ const dotRadius = computed(() => 4)
stroke-width="1.5"
/>
<!-- Zero-bank reference triangle (fixed, top center) -->
<polygon
:points="`${cx},${cy - size * 0.42} ${cx - 6},${cy - size * 0.42 - 10} ${cx + 6},${cy - size * 0.42 - 10}`"
:fill="WHITE"
/>
<!-- Zero-bank reference triangle (fixed, top center, white) -->
<polygon :points="zeroRef" :fill="WHITE" />
<!-- Bank pointer (rotates with bank) -->
<polygon
:points="bankPointer"
:transform="bankPointerTransform"
:fill="YELLOW"
/>
<!-- Bank pointer (rotates with bank, yellow) -->
<polygon :points="bankPointer" :transform="bankPointerTransform" :fill="YELLOW" />
<!-- Fixed aircraft reference symbol (W-shape) -->
<g>
<!-- Left wing -->
<rect
:x="cx - wingSpan - dotRadius"
:y="cy - wingThickness / 2"
:width="wingSpan"
:height="wingThickness"
:fill="YELLOW"
rx="1"
/>
<!-- Left wing tip (descending) -->
<line
:x1="cx - wingSpan - dotRadius"
:y1="cy"
:x2="cx - wingSpan - dotRadius"
:y2="cy + 5"
:stroke="YELLOW"
stroke-width="3"
stroke-linecap="round"
/>
<!-- Right wing -->
<rect
:x="cx + dotRadius"
:y="cy - wingThickness / 2"
:width="wingSpan"
:height="wingThickness"
:fill="YELLOW"
rx="1"
/>
<!-- Right wing tip (descending) -->
<line
:x1="cx + wingSpan + dotRadius"
:y1="cy"
:x2="cx + wingSpan + dotRadius"
:y2="cy + 5"
:stroke="YELLOW"
stroke-width="3"
stroke-linecap="round"
/>
<!-- Center dot -->
<circle
:cx="cx"
:cy="cy"
:r="dotRadius"
:fill="YELLOW"
/>
</g>
<!-- Fixed aircraft reference symbol (licarth-style L-shaped wings) -->
<!-- Center square -->
<rect
:x="cx - 5"
:y="cy - 5"
width="10"
height="10"
fill="none"
:stroke="YELLOW"
stroke-width="2"
/>
<!-- Right wing -->
<polygon
:points="rightWingPoints"
fill="black"
:stroke="YELLOW"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Left wing -->
<polygon
:points="leftWingPoints"
fill="black"
:stroke="YELLOW"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</template>

View File

@@ -16,51 +16,64 @@ const props = withDefaults(defineProps<{
speedTargetRange: null,
})
const attSize = computed(() => 280 * props.scale)
const tapeWidth = computed(() => 70 * props.scale)
const altTapeWidth = computed(() => 80 * props.scale)
const vsWidth = computed(() => 40 * props.scale)
const headingHeight = computed(() => 44 * props.scale)
const gap = computed(() => 4 * props.scale)
// Base dimensions from licarth/a320pfd reference (631.18 x 604.72)
// We add a heading tape at the bottom since the reference doesn't include one
const BASE_W = 631
const BASE_H = 550 // trimmed — no FMA labels, add heading tape at bottom
const HEADING_H = 44
const totalWidth = computed(() =>
tapeWidth.value + gap.value + attSize.value + gap.value + altTapeWidth.value + gap.value + vsWidth.value,
)
const totalHeight = computed(() =>
attSize.value + gap.value + headingHeight.value,
)
const s = computed(() => props.scale)
// Instrument dimensions (matching licarth proportions)
// Speed tape: left=25.5, top=152.4, w=100, h=319.8
const speedTape = computed(() => ({
left: 26 * s.value,
top: 120 * s.value,
width: 100 * s.value,
height: 320 * s.value,
}))
// Attitude indicator: centered at (281, 313) in reference, circular ~280px diameter
const attitude = computed(() => {
const size = 290 * s.value
return {
left: (281 - 145) * s.value,
top: (280 - 145) * s.value,
size,
}
})
// Altitude tape: left=440, top=151, w=110, h=325
const altTape = computed(() => ({
left: 440 * s.value,
top: 119 * s.value,
width: 110 * s.value,
height: 325 * s.value,
}))
// Vertical speed: left=576, top=121, w=48, h=385
const vsTape = computed(() => ({
left: 562 * s.value,
top: 89 * s.value,
width: 48 * s.value,
height: 385 * s.value,
}))
// Heading tape: below the attitude indicator
const headingTape = computed(() => ({
left: attitude.value.left - 10 * s.value,
top: (attitude.value.top + attitude.value.size + 4 * s.value),
width: attitude.value.size + 20 * s.value,
height: HEADING_H * s.value,
}))
// Total container
const totalWidth = computed(() => BASE_W * s.value)
const totalHeight = computed(() => BASE_H * s.value)
function isVisible(element: PfdElement): boolean {
return props.visibleElements.includes(element)
}
// Positions
const speedTapePos = computed(() => ({
left: 0,
top: 0,
}))
const attitudePos = computed(() => ({
left: tapeWidth.value + gap.value,
top: 0,
}))
const altTapePos = computed(() => ({
left: tapeWidth.value + attSize.value + gap.value * 2,
top: 0,
}))
const vsPos = computed(() => ({
left: tapeWidth.value + attSize.value + altTapeWidth.value + gap.value * 3,
top: 0,
}))
const headingPos = computed(() => ({
left: attitudePos.value.left - gap.value,
top: attSize.value + gap.value,
}))
const headingWidth = computed(() => attSize.value + gap.value * 2)
</script>
<template>
@@ -70,41 +83,45 @@ const headingWidth = computed(() => attSize.value + gap.value * 2)
width: `${totalWidth}px`,
height: `${totalHeight}px`,
position: 'relative',
overflow: 'hidden',
background: '#000',
}"
>
<!-- Attitude Indicator (center, behind everything) -->
<Transition name="pfd-fade">
<div
v-if="isVisible('attitude')"
:style="{
position: 'absolute',
left: `${attitude.left}px`,
top: `${attitude.top}px`,
zIndex: 0,
}"
>
<FlightlabPfdAttitudeIndicator
:pitch="pitch"
:bank-angle="bankAngle"
:size="attitude.size"
/>
</div>
</Transition>
<!-- Speed Tape (left) -->
<Transition name="pfd-fade">
<div
v-if="isVisible('speedTape')"
:style="{
position: 'absolute',
left: `${speedTapePos.left}px`,
top: `${speedTapePos.top}px`,
left: `${speedTape.left}px`,
top: `${speedTape.top}px`,
zIndex: 2,
}"
>
<FlightlabPfdSpeedTape
:speed="speed"
:target-range="speedTargetRange ?? undefined"
:width="tapeWidth"
:height="attSize"
/>
</div>
</Transition>
<!-- Attitude Indicator (center) -->
<Transition name="pfd-fade">
<div
v-if="isVisible('attitude')"
:style="{
position: 'absolute',
left: `${attitudePos.left}px`,
top: `${attitudePos.top}px`,
}"
>
<FlightlabPfdAttitudeIndicator
:pitch="pitch"
:bank-angle="bankAngle"
:size="attSize"
:width="speedTape.width"
:height="speedTape.height"
/>
</div>
</Transition>
@@ -115,14 +132,15 @@ const headingWidth = computed(() => attSize.value + gap.value * 2)
v-if="isVisible('altitudeTape')"
:style="{
position: 'absolute',
left: `${altTapePos.left}px`,
top: `${altTapePos.top}px`,
left: `${altTape.left}px`,
top: `${altTape.top}px`,
zIndex: 2,
}"
>
<FlightlabPfdAltitudeTape
:altitude="altitude"
:width="altTapeWidth"
:height="attSize"
:width="altTape.width"
:height="altTape.height"
/>
</div>
</Transition>
@@ -133,32 +151,34 @@ const headingWidth = computed(() => attSize.value + gap.value * 2)
v-if="isVisible('verticalSpeed')"
:style="{
position: 'absolute',
left: `${vsPos.left}px`,
top: `${vsPos.top}px`,
left: `${vsTape.left}px`,
top: `${vsTape.top}px`,
zIndex: 2,
}"
>
<FlightlabPfdVerticalSpeed
:vertical-speed="verticalSpeed"
:width="vsWidth"
:height="attSize"
:width="vsTape.width"
:height="vsTape.height"
/>
</div>
</Transition>
<!-- Heading Indicator (bottom, spanning below attitude) -->
<!-- Heading Indicator (bottom center) -->
<Transition name="pfd-fade">
<div
v-if="isVisible('heading')"
:style="{
position: 'absolute',
left: `${headingPos.left}px`,
top: `${headingPos.top}px`,
left: `${headingTape.left}px`,
top: `${headingTape.top}px`,
zIndex: 2,
}"
>
<FlightlabPfdHeadingIndicator
:heading="heading"
:width="headingWidth"
:height="headingHeight"
:width="headingTape.width"
:height="headingTape.height"
/>
</div>
</Transition>

View File

@@ -12,14 +12,13 @@ const uid = useId()
const clipId = computed(() => `hdg-clip-${uid}`)
const centerX = computed(() => props.width / 2)
const pixelsPerDeg = 3
const pixelsPerDeg = computed(() => props.width / 100) // ~3px/deg at 300px width
function hdgToX(deg: number): number {
let delta = deg - props.heading
// Wrap around 180 for shortest path
while (delta > 180) delta -= 360
while (delta < -180) delta += 360
return centerX.value + delta * pixelsPerDeg
return centerX.value + delta * pixelsPerDeg.value
}
const cardinals: Record<number, string> = {
@@ -36,7 +35,7 @@ function headingLabel(deg: number): string {
}
const visibleRange = computed(() => {
const halfVisible = props.width / 2 / pixelsPerDeg + 15
const halfVisible = props.width / 2 / pixelsPerDeg.value + 15
return {
min: Math.floor(props.heading - halfVisible),
max: Math.ceil(props.heading + halfVisible),
@@ -68,6 +67,8 @@ const readoutText = computed(() => {
const readoutBoxWidth = 40
const readoutBoxHeight = 18
const labelFontSize = computed(() => Math.max(9, Math.round(props.height * 0.26)))
const readoutFontSize = computed(() => Math.max(10, Math.round(props.height * 0.3)))
</script>
<template>
@@ -81,25 +82,18 @@ const readoutBoxHeight = 18
<clipPath :id="clipId">
<rect x="0" y="0" :width="width" :height="height" />
</clipPath>
<radialGradient :id="`hdg-bg-${uid}`" cx="0.5" cy="0.5" r="1">
<stop offset="0%" stop-color="#1f2a2c" />
<stop offset="57%" stop-color="#304c50" />
<stop offset="100%" stop-color="#1d282a" />
</radialGradient>
</defs>
<!-- Airbus-style heading tape -->
<!-- Background matching licarth bezel style -->
<rect
x="0"
y="0"
:width="width"
:height="height"
fill="#1a2628"
rx="1"
/>
<rect
x="1.5"
y="1.5"
:width="width - 3"
:height="height - 3"
fill="#1d282a"
stroke="#304c50"
stroke-width="0.8"
x="0" y="0"
:width="width" :height="height"
:fill="`url(#hdg-bg-${uid})`"
/>
<!-- Readout box at top center (yellow heading value) -->
@@ -108,16 +102,16 @@ const readoutBoxHeight = 18
y="2"
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="#02040b"
fill="black"
stroke="#fbe044"
stroke-width="1"
rx="2"
stroke-width="1.5"
rx="1"
/>
<text
:x="centerX"
:y="16"
:y="readoutBoxHeight - 2"
fill="#fbe044"
font-size="12"
:font-size="readoutFontSize"
font-weight="bold"
text-anchor="middle"
font-family="monospace"
@@ -125,13 +119,13 @@ const readoutBoxHeight = 18
{{ readoutText }}
</text>
<!-- Center pointer triangle (yellow, pointing down from readout box) -->
<!-- Center pointer triangle (yellow, pointing down) -->
<polygon
:points="`${centerX},${readoutBoxHeight + 6} ${centerX - 4},${readoutBoxHeight + 2} ${centerX + 4},${readoutBoxHeight + 2}`"
:points="`${centerX},${readoutBoxHeight + 6} ${centerX - 5},${readoutBoxHeight + 1} ${centerX + 5},${readoutBoxHeight + 1}`"
fill="#fbe044"
/>
<!-- Scrolling compass tape (clipped) ticks grow upward from bottom -->
<!-- Scrolling compass tape (clipped) -->
<g :clip-path="`url(#${clipId})`">
<g v-for="mark in marks" :key="mark.deg">
<!-- Tick mark (from bottom upward) -->
@@ -140,16 +134,16 @@ const readoutBoxHeight = 18
:y1="height"
:x2="mark.x"
:y2="mark.isMajor ? height - 12 : height - 7"
stroke="#f4f6fb"
stroke="white"
:stroke-width="mark.isMajor ? 1.5 : 1"
/>
<!-- Label (above tick) -->
<!-- Label -->
<text
v-if="mark.isMajor"
:x="mark.x"
:y="height - 15"
fill="#f4f6fb"
font-size="11"
fill="white"
:font-size="labelFontSize"
text-anchor="middle"
font-family="monospace"
>

View File

@@ -16,69 +16,68 @@ const props = withDefaults(defineProps<{
speedTrend: 0,
vfeSpeed: 340,
minSpeed: 130,
width: 70,
height: 300,
width: 100,
height: 320,
})
const uid = useId()
const clipId = computed(() => `spd-clip-${uid}`)
const centerY = computed(() => props.height / 2)
const pixelsPerKnot = 3
// licarth: oneKtInPx = 3.808 at height=319.8
// Scale proportionally
const pxPerKt = computed(() => props.height * 3.808 / 319.8)
// Tape area: left 68% for numbers/ticks, right 32% for tick marks edge
const tapeW = computed(() => props.width * 0.68)
function speedToY(spd: number): number {
return centerY.value + (props.speed - spd) * pixelsPerKnot
return centerY.value + (props.speed - spd) * pxPerKt.value
}
const targetZone = computed(() => {
if (!props.targetRange) return null
const min = Math.min(props.targetRange.min, props.targetRange.max)
const max = Math.max(props.targetRange.min, props.targetRange.max)
const minY = speedToY(min)
const maxY = speedToY(max)
const minY = speedToY(props.targetRange.min)
const maxY = speedToY(props.targetRange.max)
const top = Math.min(minY, maxY)
const bottom = Math.max(minY, maxY)
return {
min,
max,
top,
bottom,
height: Math.max(2, bottom - top),
}
return { top, bottom, height: Math.max(2, bottom - top) }
})
const visibleRange = computed(() => {
const halfVisible = props.height / 2 / pixelsPerKnot + 10
const halfVisible = props.height / 2 / pxPerKt.value + 10
return {
min: Math.floor((props.speed - halfVisible) / 5) * 5,
max: Math.ceil((props.speed + halfVisible) / 5) * 5,
min: Math.floor((props.speed - halfVisible) / 10) * 10,
max: Math.ceil((props.speed + halfVisible) / 10) * 10,
}
})
const marks = computed(() => {
const result: Array<{ spd: number; y: number; isLabel: boolean }> = []
for (let spd = visibleRange.value.min; spd <= visibleRange.value.max; spd += 5) {
if (spd < 0) continue
const result: Array<{ spd: number; y: number; isLabel: boolean; label: string }> = []
for (let spd = visibleRange.value.min; spd <= visibleRange.value.max; spd += 10) {
if (spd < 30) continue
const y = speedToY(spd)
const isLabel = spd % 20 === 0
result.push({ spd, y, isLabel })
const label = isLabel ? String(spd).padStart(3, '0') : ''
result.push({ spd, y, isLabel, label })
}
return result
})
const readoutText = computed(() => Math.round(props.speed).toString())
const readoutText = computed(() => Math.round(props.speed).toString().padStart(3, ' '))
const trendLineEndY = computed(() => {
if (Math.abs(props.speedTrend) < 1) return centerY.value
return centerY.value - props.speedTrend * pixelsPerKnot
return centerY.value - props.speedTrend * pxPerKt.value
})
const readoutBoxHeight = 24
const readoutBoxWidth = computed(() => props.width - 8)
const tapeInnerX = 2
const tapeInnerY = 2
const tapeInnerWidth = computed(() => props.width - 4)
const tapeInnerHeight = computed(() => props.height - 4)
// Readout box dimensions
const readoutBoxH = 26
const readoutBoxW = computed(() => props.width * 0.7)
// Font sizes scaled to width
const labelFontSize = computed(() => Math.round(props.width * 0.22))
const readoutFontSize = computed(() => Math.round(props.width * 0.24))
</script>
<template>
@@ -92,25 +91,18 @@ const tapeInnerHeight = computed(() => props.height - 4)
<clipPath :id="clipId">
<rect x="0" y="0" :width="width" :height="height" />
</clipPath>
<radialGradient :id="`spd-bg-${uid}`" cx="1" cy="0.5" r="1.2">
<stop offset="0%" stop-color="#1f2a2c" />
<stop offset="57%" stop-color="#304c50" />
<stop offset="100%" stop-color="#1d282a" />
</radialGradient>
</defs>
<!-- Airbus-style tape body -->
<!-- Background (matching back.svg gradient) -->
<rect
x="0"
y="0"
:width="width"
:height="height"
fill="#1a2628"
rx="1"
/>
<rect
:x="tapeInnerX"
:y="tapeInnerY"
:width="tapeInnerWidth"
:height="tapeInnerHeight"
fill="#1d282a"
stroke="#304c50"
stroke-width="0.8"
x="0" y="0"
:width="width" :height="height"
:fill="`url(#spd-bg-${uid})`"
/>
<!-- Scrolling tape (clipped) -->
@@ -124,13 +116,14 @@ const tapeInnerHeight = computed(() => props.height - 4)
fill="rgba(239, 40, 40, 0.45)"
/>
<line
v-if="speedToY(vfeSpeed) > 0"
v-if="speedToY(vfeSpeed) > 0 && speedToY(vfeSpeed) < height"
x1="1"
:y1="speedToY(vfeSpeed)"
:x2="width - 1"
:y2="speedToY(vfeSpeed)"
stroke="#ef4444"
stroke-width="1.5"
stroke-dasharray="9,9"
/>
<!-- Minimum speed band (red) -->
@@ -143,7 +136,7 @@ const tapeInnerHeight = computed(() => props.height - 4)
fill="rgba(239, 40, 40, 0.45)"
/>
<line
v-if="speedToY(minSpeed) < height"
v-if="speedToY(minSpeed) > 0 && speedToY(minSpeed) < height"
x1="1"
:y1="speedToY(minSpeed)"
:x2="width - 1"
@@ -163,45 +156,40 @@ const tapeInnerHeight = computed(() => props.height - 4)
stroke="rgba(45, 229, 255, 0.8)"
stroke-width="1.2"
/>
<line
x1="1"
:y1="targetZone.top"
:x2="width - 1"
:y2="targetZone.top"
stroke="rgba(45, 229, 255, 0.5)"
stroke-width="1"
/>
<line
x1="1"
:y1="targetZone.bottom"
:x2="width - 1"
:y2="targetZone.bottom"
stroke="rgba(45, 229, 255, 0.5)"
stroke-width="1"
/>
</g>
<!-- Vertical reference line (right edge of tape) -->
<line
:x1="tapeW"
y1="0"
:x2="tapeW"
:y2="height"
stroke="white"
stroke-width="1"
/>
<!-- Speed marks and labels -->
<g v-for="mark in marks" :key="mark.spd">
<!-- Tick mark -->
<!-- Tick mark (right side) -->
<line
:x1="width - 10"
:x1="tapeW"
:y1="mark.y"
:x2="width - 1"
:x2="tapeW + 14"
:y2="mark.y"
stroke="#f4f6fb"
stroke-width="1"
stroke="white"
stroke-width="1.5"
/>
<!-- Label -->
<!-- Speed number label -->
<text
v-if="mark.isLabel"
:x="width - 14"
:y="mark.y + 4"
fill="#f4f6fb"
font-size="12"
:x="tapeW - 8"
:y="mark.y + labelFontSize * 0.35"
fill="white"
:font-size="labelFontSize"
text-anchor="end"
font-family="monospace"
>
{{ mark.spd }}
{{ mark.label }}
</text>
</g>
@@ -218,22 +206,30 @@ const tapeInnerHeight = computed(() => props.height - 4)
/>
</g>
<!-- Current speed readout box -->
<!-- Bottom edge line -->
<line
x1="0" :y1="height"
:x2="tapeW + 14" :y2="height"
stroke="white"
stroke-width="1"
/>
<!-- Current speed readout box (fixed at center) -->
<rect
:x="(width - readoutBoxWidth) / 2"
:y="centerY - readoutBoxHeight / 2"
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="#02040b"
:x="(width - readoutBoxW) / 2 - 2"
:y="centerY - readoutBoxH / 2"
:width="readoutBoxW"
:height="readoutBoxH"
fill="black"
stroke="#fbe044"
stroke-width="1.4"
rx="1.5"
stroke-width="1.5"
rx="1"
/>
<text
:x="width / 2"
:y="centerY + 5"
:x="width / 2 - 4"
:y="centerY + readoutFontSize * 0.35"
fill="#3ae061"
font-size="15"
:font-size="readoutFontSize"
font-weight="bold"
text-anchor="middle"
font-family="monospace"

View File

@@ -5,53 +5,104 @@ const props = withDefaults(defineProps<{
height?: number
}>(), {
width: 48,
height: 300,
height: 385,
})
const centerY = computed(() => props.height / 2)
const axisX = computed(() => props.width * 0.56)
const wingX = computed(() => props.width * 0.92)
const scaleSpan = computed(() => props.height * 0.43)
const WHITE = '#f4f6fb'
const GREEN = '#19e34a'
const majorMarks = [1000, 2000, 4000, 6000] as const
const uid = useId()
function speedFraction(absFpm: number): number {
const f = Math.max(0, Math.min(6000, absFpm))
if (f <= 1000) return (f / 1000) * 0.24
if (f <= 2000) return 0.24 + ((f - 1000) / 1000) * 0.14
if (f <= 4000) return 0.38 + ((f - 2000) / 2000) * 0.28
return 0.66 + ((f - 4000) / 2000) * 0.24
const centerY = computed(() => props.height / 2)
const GREEN = '#3ae061'
// licarth uses meters/second for VS scale, we get ft/min
// Convert: 1 m/s = 196.85 ft/min
// licarth non-linear scale (in m/s):
// 0-10 m/s: linear, 10px per m/s → 100px range
// 10-20 m/s: 4px per m/s → 40px added
// 20-60 m/s: 1px per m/s → 40px added
// total one side: ~180px → at height=385, centerY=192.5
// We work in ft/min but scale proportionally to licarth
// Scale factor to match licarth proportions at variable height
const sf = computed(() => props.height / 385)
function vsToPixels(fpm: number): number {
// Convert ft/min to m/s
const ms = fpm / 196.85
const s = Math.sign(ms)
const a = Math.abs(ms)
let px: number
if (a <= 10) {
px = a * 10
} else if (a <= 20) {
px = 100 + (a - 10) * 4
} else if (a <= 60) {
px = 140 + (a - 20) * 1
} else {
px = 180
}
return -s * px * sf.value
}
function vsToY(fpm: number): number {
const sign = Math.sign(fpm)
if (sign === 0) return centerY.value
const frac = speedFraction(Math.abs(fpm))
return centerY.value + sign * frac * scaleSpan.value
return centerY.value + vsToPixels(fpm)
}
const markPositions = computed(() => {
return majorMarks.map((mark) => ({
mark,
topY: vsToY(mark),
bottomY: vsToY(-mark),
label: String(mark / 1000),
}))
// Scale marks matching licarth: 1, 2, 6 (in units of 1000 ft/min)
// licarth labels are 1, 2, 6 which correspond to m/s values
// Actually licarth labels 1,2,6 at pixel positions 100, 140, 180 from center
// These map to ~1000, 2000, 6000 ft/min approximately
const majorMarks = computed(() => {
const marks: Array<{ label: string; yUp: number; yDown: number; isLong: boolean }> = []
// licarth positions (from center, in pixels at original scale):
// L: 1 at 100px, 2 at 140px, 6 at 180px
// S: at 50px and 120px, and 160px from center (between major marks)
// Major marks (long, with labels)
const majors = [
{ label: '1', px: 100 },
{ label: '2', px: 140 },
{ label: '6', px: 180 },
]
for (const m of majors) {
marks.push({
label: m.label,
yUp: centerY.value - m.px * sf.value,
yDown: centerY.value + m.px * sf.value,
isLong: true,
})
}
return marks
})
const minorMarks = computed(() => {
const marks: Array<{ yUp: number; yDown: number }> = []
// licarth S marks at 50px and 120px from center
const minors = [50, 120, 160]
for (const px of minors) {
marks.push({
yUp: centerY.value - px * sf.value,
yDown: centerY.value + px * sf.value,
})
}
return marks
})
const clampedVs = computed(() => Math.max(-6000, Math.min(6000, props.verticalSpeed)))
const needleY = computed(() => vsToY(clampedVs.value))
const showReadout = computed(() => Math.abs(props.verticalSpeed) >= 100)
const readoutText = computed(() => Math.round(Math.abs(props.verticalSpeed) / 100).toString().padStart(2, '0'))
const channelShape = computed(() => {
const left = props.width * 0.18
const right = props.width
const shoulder = props.width * 0.86
const topInset = props.height * 0.11
const bottomInset = props.height * 0.89
return `${left},0 ${shoulder},0 ${right},${topInset} ${right},${bottomInset} ${shoulder},${props.height} ${left},${props.height}`
// Readout (licarth shows when |vs| > ~300 ft/min ≈ 1.5 m/s)
const showReadout = computed(() => Math.abs(props.verticalSpeed) >= 300)
const readoutText = computed(() => {
const ms = Math.abs(props.verticalSpeed) / 196.85
if (ms >= 100) return Math.floor(ms).toString()
return Math.floor(ms).toString().padStart(2, '0')
})
const readoutY = computed(() => {
const y = needleY.value
const s = Math.sign(props.verticalSpeed)
return y - s * 8 * sf.value
})
</script>
@@ -62,108 +113,124 @@ const channelShape = computed(() => {
:viewBox="`0 0 ${width} ${height}`"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Background cutout -->
<rect x="0" y="0" :width="width" :height="height" fill="#0a171d" />
<defs>
<radialGradient :id="`vs-bg-${uid}`" cx="1" cy="0.5" r="1.5">
<stop offset="0%" stop-color="#1e3233" />
<stop offset="57%" stop-color="#1e2d30" />
<stop offset="100%" stop-color="#0a171d" />
</radialGradient>
</defs>
<!-- Airbus-style VS channel -->
<polygon
:points="channelShape"
fill="#1d282a"
stroke="#304c50"
stroke-width="0.8"
<!-- Background (matching licarth DescentRate gradient) -->
<rect
x="0" y="0"
:width="width" :height="height"
:fill="`url(#vs-bg-${uid})`"
/>
<!-- Main vertical axis -->
<line
:x1="axisX"
y1="8"
:x2="axisX"
:y2="height - 8"
:stroke="WHITE"
stroke-width="1.1"
opacity="0.95"
<!-- Yellow zero reference (licarth: rect at top of SVG, -3,-2, 22x4) -->
<rect
x="0"
:y="centerY - 2"
width="22"
height="4"
fill="#fbe044"
/>
<!-- Zero marker -->
<line
:x1="axisX - 12"
:y1="centerY"
:x2="axisX + 12"
:y2="centerY"
:stroke="WHITE"
stroke-width="1.5"
/>
<!-- Major marks and labels -->
<g v-for="mark in markPositions" :key="mark.mark">
<line
:x1="axisX - 10"
:y1="mark.topY"
:x2="axisX + 10"
:y2="mark.topY"
:stroke="WHITE"
stroke-width="1.2"
/>
<line
:x1="axisX - 10"
:y1="mark.bottomY"
:x2="axisX + 10"
:y2="mark.bottomY"
:stroke="WHITE"
stroke-width="1.2"
/>
<!-- Major marks with labels -->
<g v-for="mark in majorMarks" :key="mark.label">
<!-- Up (climb) -->
<text
:x="axisX + 14"
:y="mark.topY + 4"
:fill="WHITE"
font-size="8.5"
x="5"
:y="mark.yUp + 5"
fill="white"
:font-size="17 * sf"
font-family="monospace"
text-anchor="middle"
dominant-baseline="middle"
>
{{ mark.label }}
</text>
<line
x1="11"
:y1="mark.yUp"
x2="19"
:y2="mark.yUp"
stroke="white"
stroke-width="4"
/>
<!-- Down (descent) -->
<text
:x="axisX + 14"
:y="mark.bottomY + 4"
:fill="WHITE"
font-size="8.5"
x="5"
:y="mark.yDown + 5"
fill="white"
:font-size="17 * sf"
font-family="monospace"
text-anchor="middle"
dominant-baseline="middle"
>
{{ mark.label }}
</text>
<line
x1="11"
:y1="mark.yDown"
x2="19"
:y2="mark.yDown"
stroke="white"
stroke-width="4"
/>
</g>
<!-- Trend vector (center to current VS) -->
<!-- Minor tick marks -->
<g v-for="(mark, i) in minorMarks" :key="`minor-${i}`">
<line
x1="11"
:y1="mark.yUp"
x2="19"
:y2="mark.yUp"
stroke="white"
stroke-width="2"
/>
<line
x1="11"
:y1="mark.yDown"
x2="19"
:y2="mark.yDown"
stroke="white"
stroke-width="2"
/>
</g>
<!-- VS indicator line (green, from center to value) -->
<line
:x1="axisX"
:y1="centerY"
:x2="wingX"
:y2="needleY"
x1="15"
:y1="needleY"
x2="45"
:y2="needleY * 0.4 + centerY * 0.6"
:stroke="GREEN"
stroke-width="3.6"
stroke-width="4"
stroke-linecap="round"
opacity="0.95"
/>
<circle
:cx="axisX"
:cy="centerY"
r="2"
:fill="GREEN"
/>
<!-- Numeric readout in hundreds fpm -->
<text
v-if="showReadout"
:x="wingX - 1"
:y="height - 8"
:fill="GREEN"
font-size="10"
font-weight="bold"
text-anchor="end"
font-family="monospace"
>
{{ readoutText }}
</text>
<!-- Numeric readout -->
<g v-if="showReadout">
<rect
x="18"
:y="readoutY - 10"
width="30"
height="20"
fill="black"
/>
<text
x="20"
:y="readoutY + 1"
:fill="GREEN"
:font-size="17 * sf"
font-family="monospace"
dominant-baseline="middle"
>
{{ readoutText }}
</text>
</g>
</svg>
</template>

View File

@@ -418,10 +418,11 @@ const isPitchSpeedHoldPhase = computed(() => engine.currentPhaseId.value === 'sp
// --- PFD Scale (responsive) ---
const pfdScale = computed(() => {
// Smaller when model-focus, bigger when pfd-focus
if (engine.layoutMode.value === 'model-focus') return 0.8
if (engine.layoutMode.value === 'pfd-focus') return 1.1
return 1.0
// Base dimensions are 631x550 (licarth reference proportions)
// Scale to fit learning UI layout while keeping instruments readable
if (engine.layoutMode.value === 'model-focus') return 0.7
if (engine.layoutMode.value === 'pfd-focus') return 0.95
return 0.82
})
// --- Layout ---

View File

@@ -24,14 +24,14 @@ const MAX_PITCH_UP = 30
const MAX_PITCH_DOWN = -15
const MAX_BANK = 67
const BANK_NEUTRAL_LIMIT = 33
const MAX_ROLL_RATE = 7
const ROLL_RETURN_RATE = 2
const MAX_ROLL_RATE = 12 // A320 Normal Law: ~15°/s max, 12 is responsive but not twitchy
const ROLL_RETURN_RATE = 3 // faster return to neutral when stick released
const MAX_G_PULL = 2.5
const MIN_G_PUSH = -1.0
const NEUTRAL_G = 1.0
const PITCH_RATE_PER_G_DELTA = 1.2
const PITCH_SMOOTH_TAU = 2.0 // seconds — exponential smoothing time constant
const PITCH_RATE_PER_G_DELTA = 2.5 // more responsive pitch — feel the aircraft reacting
const PITCH_SMOOTH_TAU = 0.8 // seconds — faster response, still smoothed (not instant)
const IDLE_THRUST = 2000
const MAX_THRUST = 50000