feat(pfd): add SVG PFD instrument components (attitude, speed, alt, VS, heading)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-02-20 18:33:13 +01:00
parent 2d6d8e43c3
commit 9a6eb7892b
6 changed files with 938 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
altitude: number
width?: number
height?: number
}>(), {
width: 80,
height: 300,
})
const uid = useId()
const clipId = computed(() => `alt-clip-${uid}`)
const centerY = computed(() => props.height / 2)
const pixelsPerFoot = 0.15
function altToY(alt: number): number {
return centerY.value + (props.altitude - alt) * pixelsPerFoot
}
const visibleRange = computed(() => {
const halfVisible = props.height / 2 / pixelsPerFoot + 200
return {
min: Math.floor((props.altitude - halfVisible) / 100) * 100,
max: Math.ceil((props.altitude + halfVisible) / 100) * 100,
}
})
const marks = computed(() => {
const result: Array<{ alt: number; y: number; isLabel: boolean }> = []
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 })
}
return result
})
const readoutText = computed(() => Math.round(props.altitude).toString())
const readoutBoxHeight = 24
const readoutBoxWidth = computed(() => props.width - 8)
</script>
<template>
<svg
:width="width"
:height="height"
:viewBox="`0 0 ${width} ${height}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<clipPath :id="clipId">
<rect x="0" y="0" :width="width" :height="height" />
</clipPath>
</defs>
<!-- Background -->
<rect
x="0"
y="0"
:width="width"
:height="height"
fill="rgba(10,10,30,0.95)"
rx="2"
/>
<!-- Scrolling tape (clipped) -->
<g :clip-path="`url(#${clipId})`">
<g v-for="mark in marks" :key="mark.alt">
<!-- Tick mark -->
<line
x1="0"
:y1="mark.y"
x2="8"
:y2="mark.y"
stroke="white"
stroke-width="1"
/>
<!-- Label -->
<text
v-if="mark.isLabel"
x="12"
:y="mark.y + 4"
fill="white"
font-size="12"
text-anchor="start"
font-family="monospace"
>
{{ mark.alt }}
</text>
</g>
</g>
<!-- Current altitude readout box -->
<rect
:x="(width - readoutBoxWidth) / 2"
:y="centerY - readoutBoxHeight / 2"
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="rgba(10,10,30,0.95)"
stroke="#22d3ee"
stroke-width="1.5"
rx="2"
/>
<text
:x="width / 2"
:y="centerY + 5"
fill="#22d3ee"
font-size="14"
font-weight="bold"
text-anchor="middle"
font-family="monospace"
>
{{ readoutText }}
</text>
</svg>
</template>

View File

@@ -0,0 +1,214 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
pitch: number
bankAngle: number
size?: number
}>(), {
size: 300,
})
const uid = useId()
const cx = computed(() => props.size / 2)
const cy = computed(() => props.size / 2)
const pitchScale = computed(() => props.size / 60)
const clipId = computed(() => `att-clip-${uid}`)
const skyGradId = computed(() => `att-sky-${uid}`)
const groundGradId = computed(() => `att-ground-${uid}`)
const pitchMarks = computed(() => {
const marks: Array<{ deg: number; y: number; isLabel: boolean; width: number }> = []
for (let deg = -30; deg <= 30; deg += 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 })
}
return marks
})
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 })
}
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 }
})
})
const bankPointer = computed(() => {
const r = props.size * 0.42
const s = 8
const tipX = cx.value
const tipY = cy.value - r
return `${tipX},${tipY} ${tipX - s},${tipY - s * 1.5} ${tipX + s},${tipY - s * 1.5}`
})
const horizonTransform = computed(() => {
const pitchOffset = props.pitch * pitchScale.value
return `translate(${cx.value}, ${cy.value}) rotate(${-props.bankAngle}) translate(0, ${pitchOffset})`
})
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)
</script>
<template>
<svg
:width="size"
:height="size"
:viewBox="`0 0 ${size} ${size}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<clipPath :id="clipId">
<circle :cx="cx" :cy="cy" :r="size * 0.46" />
</clipPath>
<linearGradient :id="skyGradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1a5fb4" />
<stop offset="100%" stop-color="#62a0ea" />
</linearGradient>
<linearGradient :id="groundGradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8b6914" />
<stop offset="100%" stop-color="#5e4108" />
</linearGradient>
</defs>
<!-- Clipped viewport -->
<g :clip-path="`url(#${clipId})`">
<!-- Rotating group for pitch + bank -->
<g :transform="horizonTransform">
<!-- Sky -->
<rect
:x="-size"
:y="-size * 3"
:width="size * 3"
:height="size * 3"
:fill="`url(#${skyGradId})`"
/>
<!-- Ground -->
<rect
:x="-size"
:y="0"
:width="size * 3"
:height="size * 3"
:fill="`url(#${groundGradId})`"
/>
<!-- Horizon line -->
<line
:x1="-size"
:y1="0"
:x2="size * 2"
:y2="0"
stroke="white"
stroke-width="1.5"
/>
<!-- Pitch ladder -->
<g v-for="mark in pitchMarks" :key="mark.deg">
<line
:x1="-mark.width / 2"
:y1="mark.y"
:x2="mark.width / 2"
:y2="mark.y"
stroke="white"
:stroke-width="mark.isLabel ? 1.5 : 1"
/>
<template v-if="mark.isLabel">
<text
:x="-mark.width / 2 - 6"
:y="mark.y + 4"
fill="white"
font-size="11"
text-anchor="end"
font-family="monospace"
>
{{ Math.abs(mark.deg) }}
</text>
<text
:x="mark.width / 2 + 6"
:y="mark.y + 4"
fill="white"
font-size="11"
text-anchor="start"
font-family="monospace"
>
{{ Math.abs(mark.deg) }}
</text>
</template>
</g>
</g>
</g>
<!-- Bank arc ticks (fixed) -->
<line
v-for="(tick, i) in bankTicks"
:key="i"
:x1="tick.x1"
:y1="tick.y1"
:x2="tick.x2"
:y2="tick.y2"
stroke="white"
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"
/>
<!-- Bank pointer (rotates with bank) -->
<polygon
:points="bankPointer"
:transform="bankPointerTransform"
fill="#22d3ee"
/>
<!-- Fixed aircraft reference symbol -->
<g>
<!-- Left wing -->
<rect
:x="cx - wingSpan - dotRadius"
:y="cy - wingThickness / 2"
:width="wingSpan"
:height="wingThickness"
fill="#fbbf24"
rx="1"
/>
<!-- Right wing -->
<rect
:x="cx + dotRadius"
:y="cy - wingThickness / 2"
:width="wingSpan"
:height="wingThickness"
fill="#fbbf24"
rx="1"
/>
<!-- Center dot -->
<circle
:cx="cx"
:cy="cy"
:r="dotRadius"
fill="#fbbf24"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import type { PfdElement } from '~~/shared/data/flightlab/types'
const props = withDefaults(defineProps<{
pitch: number
bankAngle: number
heading: number
speed: number
altitude: number
verticalSpeed: number
visibleElements: PfdElement[]
scale?: number
}>(), {
scale: 1,
})
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)
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,
)
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: tapeWidth.value,
top: attSize.value + gap.value,
}))
const headingWidth = computed(() => attSize.value + tapeWidth.value * 2)
</script>
<template>
<div
class="pfd-container"
:style="{
width: `${totalWidth}px`,
height: `${totalHeight}px`,
position: 'relative',
}"
>
<!-- Speed Tape (left) -->
<Transition name="pfd-fade">
<div
v-if="isVisible('speedTape')"
:style="{
position: 'absolute',
left: `${speedTapePos.left}px`,
top: `${speedTapePos.top}px`,
}"
>
<PfdSpeedTape
:speed="speed"
: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`,
}"
>
<PfdAttitudeIndicator
:pitch="pitch"
:bank-angle="bankAngle"
:size="attSize"
/>
</div>
</Transition>
<!-- Altitude Tape (right of attitude) -->
<Transition name="pfd-fade">
<div
v-if="isVisible('altitudeTape')"
:style="{
position: 'absolute',
left: `${altTapePos.left}px`,
top: `${altTapePos.top}px`,
}"
>
<PfdAltitudeTape
:altitude="altitude"
:width="altTapeWidth"
:height="attSize"
/>
</div>
</Transition>
<!-- Vertical Speed (far right) -->
<Transition name="pfd-fade">
<div
v-if="isVisible('verticalSpeed')"
:style="{
position: 'absolute',
left: `${vsPos.left}px`,
top: `${vsPos.top}px`,
}"
>
<PfdVerticalSpeed
:vertical-speed="verticalSpeed"
:width="vsWidth"
:height="attSize"
/>
</div>
</Transition>
<!-- Heading Indicator (bottom, spanning below attitude) -->
<Transition name="pfd-fade">
<div
v-if="isVisible('heading')"
:style="{
position: 'absolute',
left: `${headingPos.left}px`,
top: `${headingPos.top}px`,
}"
>
<PfdHeadingIndicator
:heading="heading"
:width="headingWidth"
:height="headingHeight"
/>
</div>
</Transition>
</div>
</template>
<style scoped>
.pfd-fade-enter-active {
transition: all 0.8s ease;
}
.pfd-fade-leave-active {
transition: all 0.4s ease;
}
.pfd-fade-enter-from {
opacity: 0;
transform: scale(0.9);
}
.pfd-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
heading: number
width?: number
height?: number
}>(), {
width: 300,
height: 44,
})
const uid = useId()
const clipId = computed(() => `hdg-clip-${uid}`)
const centerX = computed(() => props.width / 2)
const pixelsPerDeg = 3
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
}
const cardinals: Record<number, string> = {
0: 'N',
90: 'E',
180: 'S',
270: 'W',
}
function headingLabel(deg: number): string {
const normalized = ((deg % 360) + 360) % 360
if (cardinals[normalized]) return cardinals[normalized]
return (normalized / 10).toString()
}
const visibleRange = computed(() => {
const halfVisible = props.width / 2 / pixelsPerDeg + 15
return {
min: Math.floor(props.heading - halfVisible),
max: Math.ceil(props.heading + halfVisible),
}
})
const marks = computed(() => {
const result: Array<{ deg: number; x: number; isMajor: boolean; label: string }> = []
const startDeg = Math.floor(visibleRange.value.min / 5) * 5
for (let deg = startDeg; deg <= visibleRange.value.max; deg += 5) {
const x = hdgToX(deg)
const isMajor = deg % 10 === 0
const normalized = ((deg % 360) + 360) % 360
result.push({
deg,
x,
isMajor,
label: isMajor ? headingLabel(normalized) : '',
})
}
return result
})
const readoutText = computed(() => {
const h = Math.round(props.heading) % 360
const normalized = ((h % 360) + 360) % 360
return normalized.toString().padStart(3, '0')
})
const readoutBoxWidth = 36
const readoutBoxHeight = 18
</script>
<template>
<svg
:width="width"
:height="height"
:viewBox="`0 0 ${width} ${height}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<clipPath :id="clipId">
<rect x="0" y="0" :width="width" :height="height" />
</clipPath>
</defs>
<!-- Background -->
<rect
x="0"
y="0"
:width="width"
:height="height"
fill="rgba(10,10,30,0.95)"
rx="2"
/>
<!-- Scrolling compass tape (clipped) -->
<g :clip-path="`url(#${clipId})`">
<g v-for="mark in marks" :key="mark.deg">
<!-- Tick mark -->
<line
:x1="mark.x"
y1="0"
:x2="mark.x"
:y2="mark.isMajor ? 12 : 7"
stroke="white"
:stroke-width="mark.isMajor ? 1.5 : 1"
/>
<!-- Label -->
<text
v-if="mark.isMajor"
:x="mark.x"
y="24"
fill="white"
font-size="11"
text-anchor="middle"
font-family="monospace"
>
{{ mark.label }}
</text>
</g>
</g>
<!-- Center pointer triangle (cyan) -->
<polygon
:points="`${centerX},2 ${centerX - 5},0 ${centerX + 5},0`"
fill="#22d3ee"
/>
<!-- Readout box at bottom -->
<rect
:x="centerX - readoutBoxWidth / 2"
:y="height - readoutBoxHeight - 2"
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="rgba(10,10,30,0.95)"
stroke="#22d3ee"
stroke-width="1"
rx="2"
/>
<text
:x="centerX"
:y="height - 6"
fill="#22d3ee"
font-size="12"
font-weight="bold"
text-anchor="middle"
font-family="monospace"
>
{{ readoutText }}
</text>
</svg>
</template>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
speed: number
speedTrend?: number
width?: number
height?: number
}>(), {
speedTrend: 0,
width: 70,
height: 300,
})
const uid = useId()
const clipId = computed(() => `spd-clip-${uid}`)
const centerY = computed(() => props.height / 2)
const pixelsPerKnot = 3
function speedToY(spd: number): number {
return centerY.value + (props.speed - spd) * pixelsPerKnot
}
const visibleRange = computed(() => {
const halfVisible = props.height / 2 / pixelsPerKnot + 10
return {
min: Math.floor((props.speed - halfVisible) / 5) * 5,
max: Math.ceil((props.speed + halfVisible) / 5) * 5,
}
})
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 y = speedToY(spd)
const isLabel = spd % 20 === 0
result.push({ spd, y, isLabel })
}
return result
})
const readoutText = computed(() => Math.round(props.speed).toString())
const trendLineEndY = computed(() => {
if (Math.abs(props.speedTrend) < 1) return centerY.value
return centerY.value - props.speedTrend * pixelsPerKnot
})
const readoutBoxHeight = 24
const readoutBoxWidth = computed(() => props.width - 8)
</script>
<template>
<svg
:width="width"
:height="height"
:viewBox="`0 0 ${width} ${height}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<clipPath :id="clipId">
<rect x="0" y="0" :width="width" :height="height" />
</clipPath>
</defs>
<!-- Background -->
<rect
x="0"
y="0"
:width="width"
:height="height"
fill="rgba(10,10,30,0.95)"
rx="2"
/>
<!-- Scrolling tape (clipped) -->
<g :clip-path="`url(#${clipId})`">
<g v-for="mark in marks" :key="mark.spd">
<!-- Tick mark -->
<line
:x1="width - 8"
:y1="mark.y"
:x2="width"
:y2="mark.y"
stroke="white"
stroke-width="1"
/>
<!-- Label -->
<text
v-if="mark.isLabel"
:x="width - 12"
:y="mark.y + 4"
fill="white"
font-size="12"
text-anchor="end"
font-family="monospace"
>
{{ mark.spd }}
</text>
</g>
<!-- Speed trend arrow -->
<line
v-if="Math.abs(speedTrend) >= 1"
:x1="width / 2"
:y1="centerY"
:x2="width / 2"
:y2="trendLineEndY"
stroke="#22d3ee"
stroke-width="2"
opacity="0.8"
/>
</g>
<!-- Current speed readout box -->
<rect
:x="(width - readoutBoxWidth) / 2"
:y="centerY - readoutBoxHeight / 2"
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="rgba(10,10,30,0.95)"
stroke="#22d3ee"
stroke-width="1.5"
rx="2"
/>
<text
:x="width / 2"
:y="centerY + 5"
fill="#22d3ee"
font-size="15"
font-weight="bold"
text-anchor="middle"
font-family="monospace"
>
{{ readoutText }}
</text>
</svg>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
verticalSpeed: number
width?: number
height?: number
}>(), {
width: 40,
height: 300,
})
const uid = useId()
const clipId = computed(() => `vs-clip-${uid}`)
const centerY = computed(() => props.height / 2)
const scaleHeight = computed(() => props.height * 0.42)
function vsToY(fpm: number): number {
const normalized = Math.sign(fpm) * Math.sqrt(Math.abs(fpm) / 6000)
return centerY.value - normalized * scaleHeight.value
}
const majorMarks = [-6000, -4000, -2000, -1000, 0, 1000, 2000, 4000, 6000]
const marks = computed(() => {
return majorMarks.map(fpm => ({
fpm,
y: vsToY(fpm),
label: fpm === 0 ? '0' : (Math.abs(fpm) / 1000).toString(),
}))
})
const needleY = computed(() => {
const clamped = Math.max(-6000, Math.min(6000, props.verticalSpeed))
return vsToY(clamped)
})
const readoutText = computed(() => {
const rounded = Math.round(props.verticalSpeed / 50) * 50
if (rounded === 0) return '0'
return rounded > 0 ? `+${rounded}` : `${rounded}`
})
const bandY = computed(() => Math.min(centerY.value, needleY.value))
const bandHeight = computed(() => Math.abs(needleY.value - centerY.value))
</script>
<template>
<svg
:width="width"
:height="height"
:viewBox="`0 0 ${width} ${height}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<clipPath :id="clipId">
<rect x="0" y="0" :width="width" :height="height" />
</clipPath>
</defs>
<!-- Background -->
<rect
x="0"
y="0"
:width="width"
:height="height"
fill="rgba(10,10,30,0.95)"
rx="2"
/>
<g :clip-path="`url(#${clipId})`">
<!-- Scale line -->
<line
:x1="width * 0.3"
:y1="vsToY(6000)"
:x2="width * 0.3"
:y2="vsToY(-6000)"
stroke="white"
stroke-width="1"
opacity="0.4"
/>
<!-- Major marks -->
<g v-for="mark in marks" :key="mark.fpm">
<line
:x1="width * 0.15"
:y1="mark.y"
:x2="width * 0.45"
:y2="mark.y"
stroke="white"
:stroke-width="mark.fpm === 0 ? 1.5 : 1"
/>
<text
v-if="mark.fpm !== 0"
:x="width * 0.55"
:y="mark.y + 4"
fill="white"
font-size="9"
text-anchor="start"
font-family="monospace"
>
{{ mark.label }}
</text>
</g>
<!-- VS band from center to needle -->
<rect
v-if="Math.abs(verticalSpeed) > 10"
:x="width * 0.15"
:y="bandY"
:width="width * 0.3"
:height="Math.max(bandHeight, 1)"
fill="#22d3ee"
opacity="0.7"
/>
<!-- Needle indicator -->
<polygon
:points="`${width * 0.05},${needleY} ${width * 0.3},${needleY - 4} ${width * 0.3},${needleY + 4}`"
fill="#22d3ee"
/>
</g>
<!-- Readout -->
<text
v-if="Math.abs(verticalSpeed) > 50"
:x="width / 2"
:y="height - 6"
fill="#22d3ee"
font-size="9"
font-weight="bold"
text-anchor="middle"
font-family="monospace"
>
{{ readoutText }}
</text>
</svg>
</template>