mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-05-14 19:25:37 +08:00
claude hat das pfd sehr krass ueberarbeitet
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user