From 261c5516efceac2f995bde8948c81d31a295e3ef Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Mon, 21 Sep 2026 15:13:46 +0800 Subject: [PATCH] feat: radar --- .../src/components/HealthRadarChart.vue | 29 +-- .../src/components/layout/DefaultLayout.vue | 120 +++++++---- app/citizen-frontend/src/config/features.ts | 14 +- app/citizen-frontend/src/data/average.json | 57 +++++ .../src/data/fitnessAverages.ts | 23 ++ app/citizen-frontend/src/pages/checkin.vue | 1 - app/citizen-frontend/src/pages/index.vue | 89 ++++---- app/citizen-frontend/src/stores/app.ts | 69 ------ .../src/utils/physical-fitness-radar.ts | 202 ++++++++++++++++++ 9 files changed, 426 insertions(+), 178 deletions(-) create mode 100644 app/citizen-frontend/src/data/average.json create mode 100644 app/citizen-frontend/src/data/fitnessAverages.ts create mode 100644 app/citizen-frontend/src/utils/physical-fitness-radar.ts diff --git a/app/citizen-frontend/src/components/HealthRadarChart.vue b/app/citizen-frontend/src/components/HealthRadarChart.vue index 5d36345..d32bc69 100644 --- a/app/citizen-frontend/src/components/HealthRadarChart.vue +++ b/app/citizen-frontend/src/components/HealthRadarChart.vue @@ -23,7 +23,7 @@ - 心肺 ({{ radarData.cardio }}) + 心肺耐力 ({{ radarData.cardio }}) - 肌力 ({{ radarData.strength }}) + 上肢肌力 ({{ radarData.upperBodyStrength }}) - 平衡 ({{ radarData.balance }}) + 下肢肌耐力 ({{ radarData.lowerBodyStrengthAndEndurance }}) - 柔軟 ({{ radarData.flexibility }}) + 柔軟度 ({{ radarData.flexibility }}) - 敏捷 ({{ radarData.agility }}) + 平衡、敏捷與行動 ({{ radarData.balanceAgilityAndMobility }}) @@ -119,17 +119,10 @@ diff --git a/app/citizen-frontend/src/config/features.ts b/app/citizen-frontend/src/config/features.ts index 4b94a12..150c47f 100644 --- a/app/citizen-frontend/src/config/features.ts +++ b/app/citizen-frontend/src/config/features.ts @@ -1,7 +1,7 @@ -export const APPOINTMENT_ENABLED = false -export const BODY_COMPOSITION_ENABLED = false -export const BOTTOM_NAVIGATION_ENABLED = false -export const CHECKIN_ENABLED = false -export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false -export const RADAR_ENABLED = false -export const SELF_ASSESSMENT_ENABLED = true +export const APPOINTMENT_ENABLED = false; +export const BODY_COMPOSITION_ENABLED = false; +export const BOTTOM_NAVIGATION_ENABLED = false; +export const CHECKIN_ENABLED = false; +export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false; +export const RADAR_ENABLED = true; +export const SELF_ASSESSMENT_ENABLED = true; diff --git a/app/citizen-frontend/src/data/average.json b/app/citizen-frontend/src/data/average.json new file mode 100644 index 0000000..631ac1c --- /dev/null +++ b/app/citizen-frontend/src/data/average.json @@ -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" + } +] diff --git a/app/citizen-frontend/src/data/fitnessAverages.ts b/app/citizen-frontend/src/data/fitnessAverages.ts new file mode 100644 index 0000000..f2d36b4 --- /dev/null +++ b/app/citizen-frontend/src/data/fitnessAverages.ts @@ -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[]; diff --git a/app/citizen-frontend/src/pages/checkin.vue b/app/citizen-frontend/src/pages/checkin.vue index 26822bc..16f8e78 100644 --- a/app/citizen-frontend/src/pages/checkin.vue +++ b/app/citizen-frontend/src/pages/checkin.vue @@ -359,7 +359,6 @@ function simulateAllMeasurements() { } function generateReport() { - store.generateFinalReport(); reportSuccessDialog.value = true; } diff --git a/app/citizen-frontend/src/pages/index.vue b/app/citizen-frontend/src/pages/index.vue index 07cde33..d80f104 100644 --- a/app/citizen-frontend/src/pages/index.vue +++ b/app/citizen-frontend/src/pages/index.vue @@ -51,37 +51,19 @@ - - - -
肌力
+ + +
{{ summary.label }}
- {{ store.radarData.strength }} -
-
- - -
平衡
- -
- {{ store.radarData.balance }} -
-
- - -
敏捷
- -
- {{ store.radarData.agility }} -
-
- - -
心肺
- -
- {{ store.radarData.cardio }} + {{ summary.score }}
@@ -121,10 +103,19 @@ 個人五維健康力指標 - 綜合 11 項現場普測數值計算得出,
藍色為同齡正常參考值區間。 + 綜合 11 項現場普測數值計算得出,
藍色為目前樣本平均基準。
- + + 體適能資料不足,完成 11 項測驗後即可計算五維健康力指標。 + + - - - @@ -382,6 +363,7 @@ import { import { useAppStore } from '@/stores/app' import { useCourseStore } from '@/stores/course' import { getApiErrorMessage } from '@/utils/api-error' +import { calculatePhysicalFitnessRadar } from '@/utils/physical-fitness-radar' const store = useAppStore() const courseStore = useCourseStore() @@ -397,6 +379,31 @@ const physicalFitnessLoading = ref(false) const physicalFitnessError = ref('') 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' diff --git a/app/citizen-frontend/src/stores/app.ts b/app/citizen-frontend/src/stores/app.ts index fa89ccd..5382525 100644 --- a/app/citizen-frontend/src/stores/app.ts +++ b/app/citizen-frontend/src/stores/app.ts @@ -305,16 +305,6 @@ function createDefaultMeasurements(): Record { }; } -function createDefaultRadarData() { - return { - strength: 65, - balance: 60, - flexibility: 55, - agility: 70, - cardio: 62, - }; -} - export const useAppStore = defineStore("app", () => { const isLoggedIn = ref(false); const isAuthInitialized = ref(false); @@ -327,8 +317,6 @@ export const useAppStore = defineStore("app", () => { const measurements = ref>( createDefaultMeasurements(), ); - const radarData = ref(createDefaultRadarData()); - const isReportGenerated = ref(false); const completedStationsCount = computed(() => { return Object.values(measurements.value).filter( @@ -625,57 +613,6 @@ export const useAppStore = defineStore("app", () => { 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() { for (const [key, measurement] of Object.entries(measurements.value)) { if (key !== "BloodPressure" && key !== "BodyFat") { @@ -686,9 +623,6 @@ export const useAppStore = defineStore("app", () => { } } } - - isReportGenerated.value = false; - radarData.value = createDefaultRadarData(); } return { @@ -699,8 +633,6 @@ export const useAppStore = defineStore("app", () => { selfAssessment, appointments, measurements, - radarData, - isReportGenerated, completedStationsCount, totalStationsCount, progressPercent, @@ -719,7 +651,6 @@ export const useAppStore = defineStore("app", () => { cancelAppointment, checkinAppointment, saveMeasurement, - generateFinalReport, resetMeasurements, }; }); diff --git a/app/citizen-frontend/src/utils/physical-fitness-radar.ts b/app/citizen-frontend/src/utils/physical-fitness-radar.ts new file mode 100644 index 0000000..4ed40ad --- /dev/null +++ b/app/citizen-frontend/src/utils/physical-fitness-radar.ts @@ -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 | null { + const scores = new Map(); + + 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 = { + 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; +}