Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compass: face-lift widget using canvas #502

Merged
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 146 additions & 35 deletions src/components/widgets/Compass.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
<template>
<div class="compass">
<img
:class="{ rotated: widget.options.headingStyle === HeadingStyle.HEAD_UP }"
class="compass-ticks"
src="@/assets/nautic-compass.svg"
draggable="false"
/>
<img
:class="{ rotated: widget.options.headingStyle === HeadingStyle.NORTH_UP }"
class="boat"
src="@/assets/boat.svg"
draggable="false"
/>
<div ref="compassRoot" class="compass">
<canvas
ref="canvasRef"
:height="smallestDimension"
:width="smallestDimension"
class="rounded-[15%] bg-slate-950/70"
></canvas>
</div>
<Dialog v-model:show="widget.managerVars.configMenuOpen" class="w-72">
<div class="w-full h-full">
Expand All @@ -26,16 +20,37 @@
</template>

<script setup lang="ts">
import { computed, onBeforeMount, toRefs } from 'vue'
import { useElementSize } from '@vueuse/core'
import gsap from 'gsap'
import { computed, onBeforeMount, ref, toRefs } from 'vue'

import Dialog from '@/components/Dialog.vue'
import Dropdown from '@/components/Dropdown.vue'
import { degrees, radians, resetCanvas, sequentialArray } from '@/libs/utils'
import { useMainVehicleStore } from '@/stores/mainVehicle'
import type { Widget } from '@/types/widgets'

const store = useMainVehicleStore()
const compassRoot = ref()
const canvasRef = ref<HTMLCanvasElement | undefined>()
const canvasContext = ref()

const angleStyle = computed(() => `${store.attitude.yaw ?? 0}rad`)
// Object used to store current render state
const renderVariables = {
yawAngleDegrees: 0,
}

// Angles used for the main marks
const mainAngles = {
[0]: 'N',
[45]: 'NE',
[90]: 'E',
[135]: 'SE',
[180]: 'S',
[225]: 'SW',
[270]: 'W',
[315]: 'NW',
}

/**
* Possible compass configurations.
Expand Down Expand Up @@ -64,30 +79,126 @@ onBeforeMount(() => {
}
}
})

// Calculates the smallest between the widget dimensions, so we can keep the inner content always inside it, without overlays
const { width, height } = useElementSize(compassRoot)
const smallestDimension = computed(() => (width.value < height.value ? width.value : height.value))

// Renders the updated canvas state
const renderCanvas = (): void => {
if (canvasRef.value === undefined) return
if (canvasContext.value === undefined) canvasContext.value = canvasRef.value.getContext('2d')
const ctx = canvasContext.value
resetCanvas(ctx)

const halfCanvasSize = 0.5 * smallestDimension.value

// Set canvas general properties
const fontSize = 0.13 * smallestDimension.value
const baseLineWidth = 0.03 * halfCanvasSize

ctx.textAlign = 'center'
ctx.strokeStyle = 'white'
ctx.font = `bold ${fontSize}px Arial`
ctx.fillStyle = 'white'
ctx.lineWidth = baseLineWidth
ctx.textBaseline = 'middle'

const outerCircleRadius = 0.7 * halfCanvasSize
const innerIndicatorRadius = 0.4 * halfCanvasSize
const outerIndicatorRadius = 0.55 * halfCanvasSize

// Start drawing from the center
ctx.translate(halfCanvasSize, halfCanvasSize)

// Draw central angle text
ctx.font = `bold ${fontSize}px Arial`
ctx.fillText(`${renderVariables.yawAngleDegrees.toFixed(0)}°`, 0.15 * fontSize, 0)

// Set 0 degrees on the top position
ctx.rotate(radians(-90))

// Draw line and identification for each cardinal and sub-cardinal angle
for (const [angleDegrees, angleName] of Object.entries(mainAngles)) {
ctx.save()

ctx.rotate(radians(Number(angleDegrees)))
ctx.beginPath()
ctx.moveTo(outerIndicatorRadius, 0)
ctx.lineTo(outerCircleRadius, 0)

// Draw angle text
ctx.textBaseline = 'bottom'
ctx.font = `bold ${0.7 * fontSize}px Arial`
ctx.translate(outerCircleRadius * 1.025, 0)
ctx.rotate(radians(90))
ctx.fillText(angleName, 0, 0)

ctx.stroke()
ctx.restore()
}

// Draw line for each smaller angle, with 9 degree steps
for (const angleDegrees of sequentialArray(360)) {
if (angleDegrees % 9 !== 0) continue
ctx.save()
ctx.lineWidth = 0.25 * baseLineWidth
ctx.rotate(radians(Number(angleDegrees)))
ctx.beginPath()
ctx.moveTo(1.1 * outerIndicatorRadius, 0)
ctx.lineTo(outerCircleRadius, 0)
ctx.stroke()
ctx.restore()
}

// Draw outer circle
ctx.beginPath()
ctx.arc(0, 0, outerCircleRadius, 0, radians(360))
ctx.stroke()

// Draw central indicator
ctx.rotate(radians(renderVariables.yawAngleDegrees))
ctx.beginPath()
ctx.lineWidth = 1
ctx.strokeStyle = 'red'
ctx.fillStyle = 'red'
const triangleBaseSize = 0.05 * halfCanvasSize
ctx.moveTo(innerIndicatorRadius, triangleBaseSize)
ctx.lineTo(outerIndicatorRadius - 0.5 * triangleBaseSize, 0)
ctx.lineTo(innerIndicatorRadius, -triangleBaseSize)
ctx.lineTo(innerIndicatorRadius, triangleBaseSize)
ctx.closePath()
ctx.fill()
ctx.stroke()
}

// Update canvas at 60fps
setInterval(() => {
const angleDegrees = degrees(store.attitude.yaw ?? 0)
const fullRangeAngleDegrees = angleDegrees < 0 ? angleDegrees + 360 : angleDegrees

const fromWestToEast = renderVariables.yawAngleDegrees > 270 && fullRangeAngleDegrees < 90
const fromEastToWest = renderVariables.yawAngleDegrees < 90 && fullRangeAngleDegrees > 270
// If cruzing 0 degrees, use a chained animation, so the pointer does not turn 360 degrees to the other side (visual artifact)
if (fromWestToEast) {
gsap.to(renderVariables, 0.05, { yawAngleDegrees: 0 })
gsap.fromTo(renderVariables, 0.05, { yawAngleDegrees: 0 }, { yawAngleDegrees: fullRangeAngleDegrees })
} else if (fromEastToWest) {
gsap.to(renderVariables, 0.05, { yawAngleDegrees: 360 })
gsap.fromTo(renderVariables, 0.05, { yawAngleDegrees: 360 }, { yawAngleDegrees: fullRangeAngleDegrees })
} else {
gsap.to(renderVariables, 0.1, { yawAngleDegrees: fullRangeAngleDegrees })
}
renderCanvas()
}, 1000 / 60)
</script>

<style scoped>
.compass {
position: relative;
}
.compass-ticks {
transition: -webkit-transform 0.2s;
user-select: none;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
opacity: 0.9;
}
.boat {
position: absolute;
margin: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
user-select: none;
height: 55%;
}
.rotated {
transform: rotate(v-bind('angleStyle'));
height: 100%;
}
</style>
Loading