feat: radar

This commit is contained in:
skytek_xinliang
2026-09-21 15:13:46 +08:00
parent be1cf7d978
commit 261c5516ef
9 changed files with 426 additions and 178 deletions
@@ -23,7 +23,7 @@
<polygon <polygon
fill="rgba(33, 150, 243, 0.12)" fill="rgba(33, 150, 243, 0.12)"
:points="getPolygonPoints(65)" :points="getPolygonPoints(50)"
stroke="#2196F3" stroke="#2196F3"
stroke-dasharray="1 1" stroke-dasharray="1 1"
stroke-width="0.5" stroke-width="0.5"
@@ -55,7 +55,7 @@
x="60" x="60"
y="8" y="8"
> >
心肺 ({{ radarData.cardio }}) 心肺耐力 ({{ radarData.cardio }})
</text> </text>
<text <text
@@ -66,7 +66,7 @@
x="110" x="110"
y="44" y="44"
> >
肌力 ({{ radarData.strength }}) 上肢肌力 ({{ radarData.upperBodyStrength }})
</text> </text>
<text <text
@@ -77,7 +77,7 @@
x="94" x="94"
y="104" y="104"
> >
平衡 ({{ radarData.balance }}) 下肢肌耐力 ({{ radarData.lowerBodyStrengthAndEndurance }})
</text> </text>
<text <text
@@ -88,7 +88,7 @@
x="26" x="26"
y="104" y="104"
> >
柔軟 ({{ radarData.flexibility }}) 柔軟度 ({{ radarData.flexibility }})
</text> </text>
<text <text
@@ -99,7 +99,7 @@
x="10" x="10"
y="44" y="44"
> >
敏捷 ({{ radarData.agility }}) 平衡、敏捷與行動 ({{ radarData.balanceAgilityAndMobility }})
</text> </text>
</svg> </svg>
@@ -119,17 +119,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import type { PhysicalFitnessRadarData } from "@/utils/physical-fitness-radar";
interface RadarData {
strength: number;
balance: number;
flexibility: number;
agility: number;
cardio: number;
}
const { radarData } = defineProps<{ const { radarData } = defineProps<{
radarData: RadarData; radarData: PhysicalFitnessRadarData;
}>(); }>();
const centerX = 60; const centerX = 60;
@@ -160,10 +153,10 @@ function getPolygonPoints(percent: number) {
const userDataCoordArray = computed(() => { const userDataCoordArray = computed(() => {
const values = [ const values = [
radarData.cardio, radarData.cardio,
radarData.strength, radarData.upperBodyStrength,
radarData.balance, radarData.lowerBodyStrengthAndEndurance,
radarData.flexibility, radarData.flexibility,
radarData.agility, radarData.balanceAgilityAndMobility,
]; ];
return values.map((value, index) => { return values.map((value, index) => {
@@ -6,7 +6,9 @@
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" /> <v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
</template> </template>
<v-app-bar-title class="font-weight-bold flex-grow-1"> 運動玩轉健康力 </v-app-bar-title> <v-app-bar-title class="font-weight-bold flex-grow-1">
運動玩轉健康力
</v-app-bar-title>
<template #append> <template #append>
<div class="d-flex align-center"> <div class="d-flex align-center">
@@ -19,7 +21,10 @@
variant="text" variant="text"
@click="isLargeText = !isLargeText" @click="isLargeText = !isLargeText"
> >
<v-icon :color="isLargeText ? 'accent' : 'white'" icon="mdi-format-size" /> <v-icon
:color="isLargeText ? 'accent' : 'white'"
icon="mdi-format-size"
/>
</v-btn> </v-btn>
</div> </div>
</template> </template>
@@ -37,7 +42,7 @@
<template #prepend> <template #prepend>
<v-avatar color="accent" size="48"> <v-avatar color="accent" size="48">
<span class="text-white font-weight-bold text-h6"> <span class="text-white font-weight-bold text-h6">
{{ (store.userProfile.name || '姓名').substring(0, 1) }} {{ (store.userProfile.name || "姓名").substring(0, 1) }}
</span> </span>
</v-avatar> </v-avatar>
</template> </template>
@@ -58,7 +63,9 @@
</v-avatar> </v-avatar>
</template> </template>
<v-list-item-subtitle class="text-white-50">未登入訪客</v-list-item-subtitle> <v-list-item-subtitle class="text-white-50"
>未登入訪客</v-list-item-subtitle
>
</v-list-item> </v-list-item>
<v-divider /> <v-divider />
@@ -71,7 +78,12 @@
<v-list-item-title>大字體模式</v-list-item-title> <v-list-item-title>大字體模式</v-list-item-title>
<template #append> <template #append>
<v-switch v-model="isLargeText" color="secondary" density="compact" hide-details /> <v-switch
v-model="isLargeText"
color="secondary"
density="compact"
hide-details
/>
</template> </template>
</v-list-item> </v-list-item>
@@ -172,7 +184,10 @@
</v-navigation-drawer> </v-navigation-drawer>
<!-- 主要內容區 --> <!-- 主要內容區 -->
<v-main class="bg-white min-vh-100" :class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"> <v-main
class="bg-white min-vh-100"
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
>
<v-container class="px-4 py-2"> <v-container class="px-4 py-2">
<slot name="main" /> <slot name="main" />
</v-container> </v-container>
@@ -189,11 +204,17 @@
tag="div" tag="div"
> >
<v-btn to="/" value="home"> <v-btn to="/" value="home">
<v-icon :color="getBottomNavIconColor('home')">mdi-view-dashboard</v-icon> <v-icon :color="getBottomNavIconColor('home')"
>mdi-view-dashboard</v-icon
>
<span class="bottom-nav-text">首頁</span> <span class="bottom-nav-text">首頁</span>
</v-btn> </v-btn>
<v-btn v-if="SELF_ASSESSMENT_ENABLED" to="/self-assessment" value="self-assessment"> <v-btn
v-if="SELF_ASSESSMENT_ENABLED"
to="/self-assessment"
value="self-assessment"
>
<v-icon :color="getBottomNavIconColor('self-assessment')"> <v-icon :color="getBottomNavIconColor('self-assessment')">
mdi-clipboard-text-search mdi-clipboard-text-search
</v-icon> </v-icon>
@@ -201,13 +222,25 @@
<span class="bottom-nav-text">自評</span> <span class="bottom-nav-text">自評</span>
</v-btn> </v-btn>
<v-btn v-if="CHECKIN_ENABLED && store.isLoggedIn" to="/checkin" value="checkin"> <v-btn
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon> v-if="CHECKIN_ENABLED && store.isLoggedIn"
to="/checkin"
value="checkin"
>
<v-icon :color="getBottomNavIconColor('checkin')"
>mdi-qrcode-scan</v-icon
>
<span class="bottom-nav-text">量測</span> <span class="bottom-nav-text">量測</span>
</v-btn> </v-btn>
<v-btn v-if="APPOINTMENT_ENABLED && store.isLoggedIn" to="/appointment" value="appointment"> <v-btn
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon> v-if="APPOINTMENT_ENABLED && store.isLoggedIn"
to="/appointment"
value="appointment"
>
<v-icon :color="getBottomNavIconColor('appointment')"
>mdi-calendar-check</v-icon
>
<span class="bottom-nav-text">預約</span> <span class="bottom-nav-text">預約</span>
</v-btn> </v-btn>
</v-bottom-navigation> </v-bottom-navigation>
@@ -215,7 +248,9 @@
<!-- 隱私授權對話框 --> <!-- 隱私授權對話框 -->
<v-dialog v-model="showPrivacy" max-width="500"> <v-dialog v-model="showPrivacy" max-width="500">
<v-card class="rounded-xl"> <v-card class="rounded-xl">
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center"> <v-card-title
class="bg-primary text-white font-weight-bold d-flex align-center"
>
<v-icon class="mr-2" icon="mdi-shield-check" /> <v-icon class="mr-2" icon="mdi-shield-check" />
個資同意與資料授權 個資同意與資料授權
</v-card-title> </v-card-title>
@@ -232,7 +267,7 @@
</ul> </ul>
<p class="text-caption text-grey"> <p class="text-caption text-grey">
授權狀態:{{ store.userProfile.consent ? '已同意授權' : '未授權' }} 授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
</p> </p>
</v-card-text> </v-card-text>
@@ -252,57 +287,58 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from "vue";
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from "vue-router";
import { useDisplayUserName } from '@/composables/useDisplayUserName' import { useDisplayUserName } from "@/composables/useDisplayUserName";
import { import {
APPOINTMENT_ENABLED, APPOINTMENT_ENABLED,
BODY_COMPOSITION_ENABLED, BODY_COMPOSITION_ENABLED,
BOTTOM_NAVIGATION_ENABLED, BOTTOM_NAVIGATION_ENABLED,
CHECKIN_ENABLED, CHECKIN_ENABLED,
SELF_ASSESSMENT_ENABLED, SELF_ASSESSMENT_ENABLED,
} from '@/config/features' } from "@/config/features";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
import { useCourseStore } from '@/stores/course' import { useCourseStore } from "@/stores/course";
const store = useAppStore() const store = useAppStore();
const courseStore = useCourseStore() const courseStore = useCourseStore();
const router = useRouter() const router = useRouter();
const route = useRoute() const route = useRoute();
const displayUserName = useDisplayUserName(store.userProfile) const displayUserName = useDisplayUserName(store.userProfile);
const drawer = ref(false) const drawer = ref(false);
const activeTab = ref('home') const activeTab = ref("home");
const showPrivacy = ref(false) const showPrivacy = ref(false);
// 無障礙狀態 (可持久化儲存於 localStorage) // 無障礙狀態 (可持久化儲存於 localStorage)
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true') const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
watch(isLargeText, (newVal) => { watch(isLargeText, (newVal) => {
localStorage.setItem('mode-large-text', String(newVal)) localStorage.setItem("mode-large-text", String(newVal));
}) });
// 同步底部導航狀態與當前路由 // 同步底部導航狀態與當前路由
watch( watch(
() => route.path, () => route.path,
(path) => { (path) => {
if (path === '/') activeTab.value = 'home' if (path === "/") activeTab.value = "home";
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment' else if (path.startsWith("/self-assessment"))
else if (path.startsWith('/appointment')) activeTab.value = 'appointment' activeTab.value = "self-assessment";
else if (path.startsWith('/checkin')) activeTab.value = 'checkin' else if (path.startsWith("/appointment")) activeTab.value = "appointment";
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
}, },
{ immediate: true } { immediate: true },
) );
function getBottomNavIconColor(value: string) { function getBottomNavIconColor(value: string) {
return activeTab.value === value ? 'secondary' : 'secondary-darken-1' return activeTab.value === value ? "secondary" : "secondary-darken-1";
} }
async function handleLogout() { async function handleLogout() {
await store.logout() await store.logout();
courseStore.resetGroup() courseStore.resetGroup();
await router.push('/login') await router.push("/login");
drawer.value = false drawer.value = false;
} }
</script> </script>
+7 -7
View File
@@ -1,7 +1,7 @@
export const APPOINTMENT_ENABLED = false export const APPOINTMENT_ENABLED = false;
export const BODY_COMPOSITION_ENABLED = false export const BODY_COMPOSITION_ENABLED = false;
export const BOTTOM_NAVIGATION_ENABLED = false export const BOTTOM_NAVIGATION_ENABLED = false;
export const CHECKIN_ENABLED = false export const CHECKIN_ENABLED = false;
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
export const RADAR_ENABLED = false export const RADAR_ENABLED = true;
export const SELF_ASSESSMENT_ENABLED = true export const SELF_ASSESSMENT_ENABLED = true;
@@ -0,0 +1,57 @@
[
{
"fitnessCode": "UPGO_244M",
"fitnessName": "繞物行走 2.44m(秒)",
"average": "5.042"
},
{
"fitnessCode": "SIT_STAND_30S",
"fitnessName": "坐站次數 30 秒(次)",
"average": "19.125"
},
{
"fitnessCode": "SIT_REACH_L",
"fitnessName": "左腳椅子坐姿體前彎(cm)",
"average": "-0.492"
},
{
"fitnessCode": "SIT_REACH_R",
"fitnessName": "右腳椅子坐姿體前彎(cm)",
"average": "2.463"
},
{
"fitnessCode": "ARM_CURL_L",
"fitnessName": "左手肱二頭肌手臂屈舉(次)",
"average": "17.8"
},
{
"fitnessCode": "ARM_CURL_R",
"fitnessName": "右手肱二頭肌手臂屈舉(次)",
"average": "17.154"
},
{
"fitnessCode": "BACK_SCRATCH_L",
"fitnessName": "左手抓背測驗(cm)",
"average": "-5.338"
},
{
"fitnessCode": "BACK_SCRATCH_R",
"fitnessName": "右手抓背測驗(cm)",
"average": "-2.065"
},
{
"fitnessCode": "ONE_LEG_STAND_L",
"fitnessName": "靜態平衡開眼左足立(秒)",
"average": "29.861"
},
{
"fitnessCode": "ONE_LEG_STAND_R",
"fitnessName": "靜態平衡開眼右足立(秒)",
"average": "43.06"
},
{
"fitnessCode": "STEP_TEST_30S",
"fitnessName": "心肺耐力原地抬膝踏步(次)",
"average": "92.539"
}
]
@@ -0,0 +1,23 @@
import fitnessAverages from "./average.json";
export type FitnessCode =
| "UPGO_244M"
| "SIT_STAND_30S"
| "SIT_REACH_L"
| "SIT_REACH_R"
| "ARM_CURL_L"
| "ARM_CURL_R"
| "BACK_SCRATCH_L"
| "BACK_SCRATCH_R"
| "ONE_LEG_STAND_L"
| "ONE_LEG_STAND_R"
| "STEP_TEST_30S";
export interface FitnessAverageItem {
readonly fitnessCode: FitnessCode;
readonly fitnessName: string;
readonly average: string;
}
export const FITNESS_AVERAGES =
fitnessAverages as readonly FitnessAverageItem[];
@@ -359,7 +359,6 @@ function simulateAllMeasurements() {
} }
function generateReport() { function generateReport() {
store.generateFinalReport();
reportSuccessDialog.value = true; reportSuccessDialog.value = true;
} }
+48 -41
View File
@@ -51,37 +51,19 @@
</v-btn> </v-btn>
</v-card-subtitle> </v-card-subtitle>
<!-- 四項核心能力數值簡覽 --> <v-row v-if="RADAR_ENABLED && physicalFitnessRadarData" class="mt-4" no-gutters>
<v-row v-if="RADAR_ENABLED" class="mt-4" no-gutters> <v-col
<v-col class="text-center border pa-2" cols="3"> v-for="summary in radarSummaries"
<div class="">肌力</div> :key="summary.label"
class="text-center border pa-2"
cols="12"
sm="6"
md="4"
>
<div>{{ summary.label }}</div>
<div class="font-weight-black"> <div class="font-weight-black">
{{ store.radarData.strength }} {{ summary.score }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">平衡</div>
<div class="font-weight-black">
{{ store.radarData.balance }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">敏捷</div>
<div class="font-weight-black">
{{ store.radarData.agility }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">心肺</div>
<div class="font-weight-black">
{{ store.radarData.cardio }}
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
@@ -121,10 +103,19 @@
<v-card-title> 個人五維健康力指標 </v-card-title> <v-card-title> 個人五維健康力指標 </v-card-title>
<v-card-subtitle> <v-card-subtitle>
綜合 11 項現場普測數值計算得出,<br />藍色為同齡正常參考值區間。 綜合 11 項現場普測數值計算得出,<br />藍色為目前樣本平均基準。
</v-card-subtitle> </v-card-subtitle>
<HealthRadarChart :radar-data="store.radarData" /> <v-alert
v-if="!physicalFitnessRadarData"
class="ma-4"
density="compact"
type="info"
variant="tonal"
>
體適能資料不足,完成 11 項測驗後即可計算五維健康力指標。
</v-alert>
<HealthRadarChart v-else :radar-data="physicalFitnessRadarData" />
</v-card> </v-card>
<ModuleRouterCard <ModuleRouterCard
v-if="BODY_COMPOSITION_ENABLED" v-if="BODY_COMPOSITION_ENABLED"
@@ -142,15 +133,6 @@
@refresh="loadPhysicalFitness" @refresh="loadPhysicalFitness"
/> />
<ModuleRouterCard
v-if="SELF_ASSESSMENT_ENABLED"
description="完成線上自評,取得初步風險分級。"
icon="mdi-clipboard-text-search"
icon-color="accent"
title="自評"
to="/self-assessment"
/>
<ModuleRouterCard <ModuleRouterCard
v-if="CHECKIN_ENABLED" v-if="CHECKIN_ENABLED"
description="完成數值紀錄,建立個人功能基線。" description="完成數值紀錄,建立個人功能基線。"
@@ -244,7 +226,6 @@
</div> </div>
</div> </div>
</v-card> </v-card>
</template> </template>
<IntroductionCarousel v-if="!store.isLoggedIn" /> <IntroductionCarousel v-if="!store.isLoggedIn" />
@@ -382,6 +363,7 @@ import {
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useCourseStore } from '@/stores/course' import { useCourseStore } from '@/stores/course'
import { getApiErrorMessage } from '@/utils/api-error' import { getApiErrorMessage } from '@/utils/api-error'
import { calculatePhysicalFitnessRadar } from '@/utils/physical-fitness-radar'
const store = useAppStore() const store = useAppStore()
const courseStore = useCourseStore() const courseStore = useCourseStore()
@@ -397,6 +379,31 @@ const physicalFitnessLoading = ref(false)
const physicalFitnessError = ref('') const physicalFitnessError = ref('')
const displayUserName = useDisplayUserName(store.userProfile) const displayUserName = useDisplayUserName(store.userProfile)
const physicalFitnessRadarData = computed(() =>
calculatePhysicalFitnessRadar(physicalFitnessItems.value)
)
const radarSummaries = computed(() => {
if (!physicalFitnessRadarData.value) {
return []
}
return [
{
label: '上肢肌力',
score: physicalFitnessRadarData.value.upperBodyStrength,
},
{
label: '下肢肌耐力',
score: physicalFitnessRadarData.value.lowerBodyStrengthAndEndurance,
},
{ label: '心肺耐力', score: physicalFitnessRadarData.value.cardio },
{
label: '平衡、敏捷與行動',
score: physicalFitnessRadarData.value.balanceAgilityAndMobility,
},
{ label: '柔軟度', score: physicalFitnessRadarData.value.flexibility },
]
})
const MISSING_GROUP_DIALOG_KEY = 'missing-fitness-group-dialog-shown' const MISSING_GROUP_DIALOG_KEY = 'missing-fitness-group-dialog-shown'
-69
View File
@@ -305,16 +305,6 @@ function createDefaultMeasurements(): Record<string, MeasurementStation> {
}; };
} }
function createDefaultRadarData() {
return {
strength: 65,
balance: 60,
flexibility: 55,
agility: 70,
cardio: 62,
};
}
export const useAppStore = defineStore("app", () => { export const useAppStore = defineStore("app", () => {
const isLoggedIn = ref(false); const isLoggedIn = ref(false);
const isAuthInitialized = ref(false); const isAuthInitialized = ref(false);
@@ -327,8 +317,6 @@ export const useAppStore = defineStore("app", () => {
const measurements = ref<Record<string, MeasurementStation>>( const measurements = ref<Record<string, MeasurementStation>>(
createDefaultMeasurements(), createDefaultMeasurements(),
); );
const radarData = ref(createDefaultRadarData());
const isReportGenerated = ref(false);
const completedStationsCount = computed(() => { const completedStationsCount = computed(() => {
return Object.values(measurements.value).filter( return Object.values(measurements.value).filter(
@@ -625,57 +613,6 @@ export const useAppStore = defineStore("app", () => {
measurement.time = timeStr; measurement.time = timeStr;
} }
function generateFinalReport() {
const currentMeasurements = measurements.value;
let strScore = 65;
if (currentMeasurements.GripStrength.completed) {
const avgGrip =
((currentMeasurements.GripStrength.values.leftHand || 0) +
(currentMeasurements.GripStrength.values.rightHand || 0)) /
2;
const limit = userProfile.value.gender === "男" ? 30 : 20;
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)));
}
let balScore = 60;
if (currentMeasurements.OneLegTest.completed) {
const maxSec = Math.max(
currentMeasurements.OneLegTest.values.leftSeconds || 0,
currentMeasurements.OneLegTest.values.rightSeconds || 0,
);
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)));
}
let flexScore = 55;
if (currentMeasurements.SitReachTest.completed) {
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0;
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5));
}
let agiScore = 70;
if (currentMeasurements.UpGo8ft.completed) {
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10;
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)));
}
let cardScore = 62;
if (currentMeasurements.StepTest.completed) {
const count = currentMeasurements.StepTest.values.count || 60;
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)));
}
radarData.value = {
strength: strScore,
balance: balScore,
flexibility: flexScore,
agility: agiScore,
cardio: cardScore,
};
isReportGenerated.value = true;
}
function resetMeasurements() { function resetMeasurements() {
for (const [key, measurement] of Object.entries(measurements.value)) { for (const [key, measurement] of Object.entries(measurements.value)) {
if (key !== "BloodPressure" && key !== "BodyFat") { if (key !== "BloodPressure" && key !== "BodyFat") {
@@ -686,9 +623,6 @@ export const useAppStore = defineStore("app", () => {
} }
} }
} }
isReportGenerated.value = false;
radarData.value = createDefaultRadarData();
} }
return { return {
@@ -699,8 +633,6 @@ export const useAppStore = defineStore("app", () => {
selfAssessment, selfAssessment,
appointments, appointments,
measurements, measurements,
radarData,
isReportGenerated,
completedStationsCount, completedStationsCount,
totalStationsCount, totalStationsCount,
progressPercent, progressPercent,
@@ -719,7 +651,6 @@ export const useAppStore = defineStore("app", () => {
cancelAppointment, cancelAppointment,
checkinAppointment, checkinAppointment,
saveMeasurement, saveMeasurement,
generateFinalReport,
resetMeasurements, resetMeasurements,
}; };
}); });
@@ -0,0 +1,202 @@
import type { PhysicalFitnessItem } from "@/api/types";
import { FITNESS_AVERAGES, type FitnessCode } from "@/data/fitnessAverages";
export interface PhysicalFitnessRadarData {
upperBodyStrength: number;
lowerBodyStrengthAndEndurance: number;
cardio: number;
balanceAgilityAndMobility: number;
flexibility: number;
}
type RadarDimension = keyof PhysicalFitnessRadarData;
interface FitnessTestDefinition {
code: FitnessCode;
dimension: RadarDimension;
direction: 1 | -1;
scale: "average" | "flexibility";
}
const FITNESS_TEST_DEFINITIONS: readonly FitnessTestDefinition[] = [
{
code: "ARM_CURL_L",
dimension: "upperBodyStrength",
direction: 1,
scale: "average",
},
{
code: "ARM_CURL_R",
dimension: "upperBodyStrength",
direction: 1,
scale: "average",
},
{
code: "SIT_STAND_30S",
dimension: "lowerBodyStrengthAndEndurance",
direction: 1,
scale: "average",
},
{
code: "STEP_TEST_30S",
dimension: "cardio",
direction: 1,
scale: "average",
},
{
code: "ONE_LEG_STAND_L",
dimension: "balanceAgilityAndMobility",
direction: 1,
scale: "average",
},
{
code: "ONE_LEG_STAND_R",
dimension: "balanceAgilityAndMobility",
direction: 1,
scale: "average",
},
{
code: "UPGO_244M",
dimension: "balanceAgilityAndMobility",
direction: -1,
scale: "average",
},
{
code: "BACK_SCRATCH_L",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "BACK_SCRATCH_R",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "SIT_REACH_L",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "SIT_REACH_R",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
];
const FLEXIBILITY_SCALE = 10;
function parseFiniteNumber(value: string | null): number | null {
if (value === null || value.trim() === "") {
return null;
}
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
function createFitnessScoreMap(
items: readonly PhysicalFitnessItem[],
): Map<FitnessCode, number> | null {
const scores = new Map<FitnessCode, number>();
for (const item of items) {
const definition = FITNESS_TEST_DEFINITIONS.find(
({ code }) => code === item.fitnessCode,
);
if (!definition) {
continue;
}
const score = parseFiniteNumber(item.fitnessScore);
if (score === null || scores.has(definition.code)) {
return null;
}
scores.set(definition.code, score);
}
return scores;
}
function calculateRelativeScore(
score: number,
average: number,
direction: 1 | -1,
scale: FitnessTestDefinition["scale"],
): number | null {
const comparisonScale =
scale === "flexibility" ? FLEXIBILITY_SCALE : Math.abs(average);
if (comparisonScale === 0) {
return null;
}
const difference = (direction * (score - average)) / comparisonScale;
return 50 + 50 * Math.tanh(difference);
}
export function calculatePhysicalFitnessRadar(
items: readonly PhysicalFitnessItem[],
): PhysicalFitnessRadarData | null {
const fitnessScores = createFitnessScoreMap(items);
if (!fitnessScores) {
return null;
}
const averages = new Map(
FITNESS_AVERAGES.map((item) => [item.fitnessCode, Number(item.average)]),
);
const dimensionScores: Record<RadarDimension, number[]> = {
upperBodyStrength: [],
lowerBodyStrengthAndEndurance: [],
cardio: [],
balanceAgilityAndMobility: [],
flexibility: [],
};
for (const definition of FITNESS_TEST_DEFINITIONS) {
const score = fitnessScores.get(definition.code);
const average = averages.get(definition.code);
if (
score === undefined ||
average === undefined ||
!Number.isFinite(average)
) {
return null;
}
const relativeScore = calculateRelativeScore(
score,
average,
definition.direction,
definition.scale,
);
if (relativeScore === null) {
return null;
}
dimensionScores[definition.dimension].push(relativeScore);
}
return {
upperBodyStrength: Math.round(
averageDimensionScore(dimensionScores.upperBodyStrength),
),
lowerBodyStrengthAndEndurance: Math.round(
averageDimensionScore(dimensionScores.lowerBodyStrengthAndEndurance),
),
cardio: Math.round(averageDimensionScore(dimensionScores.cardio)),
balanceAgilityAndMobility: Math.round(
averageDimensionScore(dimensionScores.balanceAgilityAndMobility),
),
flexibility: Math.round(averageDimensionScore(dimensionScores.flexibility)),
};
}
function averageDimensionScore(scores: readonly number[]): number {
return scores.reduce((total, score) => total + score, 0) / scores.length;
}