mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
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:
118
app/components/flightlab/pfd/PfdAltitudeTape.vue
Normal file
118
app/components/flightlab/pfd/PfdAltitudeTape.vue
Normal 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>
|
||||
214
app/components/flightlab/pfd/PfdAttitudeIndicator.vue
Normal file
214
app/components/flightlab/pfd/PfdAttitudeIndicator.vue
Normal 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>
|
||||
179
app/components/flightlab/pfd/PfdContainer.vue
Normal file
179
app/components/flightlab/pfd/PfdContainer.vue
Normal 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>
|
||||
152
app/components/flightlab/pfd/PfdHeadingIndicator.vue
Normal file
152
app/components/flightlab/pfd/PfdHeadingIndicator.vue
Normal 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>
|
||||
138
app/components/flightlab/pfd/PfdSpeedTape.vue
Normal file
138
app/components/flightlab/pfd/PfdSpeedTape.vue
Normal 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>
|
||||
137
app/components/flightlab/pfd/PfdVerticalSpeed.vue
Normal file
137
app/components/flightlab/pfd/PfdVerticalSpeed.vue
Normal 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>
|
||||
Reference in New Issue
Block a user