diff --git a/CHANGELOG.md b/CHANGELOG.md index a81b52f..ade903a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased — 0.1.7 +- Aligned the Web front/back BODY ZONES figures with Android's normalized + Compose Canvas silhouettes. The Web now uses Android's head, neck and 11 + region paths per face, while retaining the existing Catppuccin intensity + classes, 30-day session counts, legend, labels and keyboard focus behavior. + No Android, domain, database or synchronization behavior changed. + - Widened the Sleep diary PDF treatment/remarks column from 84 to 180 pt and raised its text from 4 to 7.2 pt. Width-aware wrapping keeps paragraphs, separate medication intakes, accents, doses, units, and quantities visible. diff --git a/docs/architecture.md b/docs/architecture.md index 6d45bbf..6ed0d09 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -358,11 +358,16 @@ SVG was selected over Canvas because the bounded series has very few elements and vector sharpness/responsive scaling matter more than high-volume drawing. All colours are read from the live Catppuccin CSS variables. -The muscle figure is an original code-native SVG. Canonical localized mappings -are: `chest` to bilateral front chest; `back` to rear upper back and bilateral -lats; `shoulders` to bilateral front/rear shoulders; `arms` to bilateral -front/rear arms; `core` to the front trunk; `glutes` to bilateral rear glutes; -`thighs` and `calves` to bilateral front/rear legs. Aggregate zones +The muscle figure is a code-native SVG whose geometry is ported from the +normalized Compose Canvas in Android `HomeScreen.kt`: `drawOval()` for the +head, `neckPath()` for the neck, and `bodyRegions()` for 11 paths on each +face. The SVG viewBox uses Android fractions multiplied by 100 and scales to +a portrait ratio based on Android's 236dp tall two-panel Home layout. +Canonical localized mappings are: `chest` to +bilateral front chest; `back` to the contiguous rear trunk; `shoulders` to +bilateral front/rear shoulders; `arms` to bilateral front/rear arms; `core` to +the front trunk; `glutes` to bilateral rear glutes; `thighs` and `calves` to +bilateral front/rear legs. Aggregate zones `full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and remain in the complete text list. Visual levels are a deterministic ratio to the maximum `session_count` in the current snapshot: zero, up to one third, up diff --git a/docs/current_state.md b/docs/current_state.md index 196ddd7..66ec49d 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -743,9 +743,12 @@ partial, invalid and transport-error states never synthesize values. `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real point remains visible, `smooth=false`, legacy zeroes are preserved, and only -Core-owned `improved` flags alter markers. An original React SVG supplies -front/back BODY ZONES regions whose discrete colour level depends solely on -`session_count`; the factual text list remains authoritative and accessible. +Core-owned `improved` flags alter markers. A React SVG ports the exact +normalized head, neck, and 11 front/11 back BODY ZONES paths from Android +`HomeScreen.kt` `bodyRegions()` and `neckPath()`. Its discrete Catppuccin colour +level still depends solely on `session_count`; the factual text list remains +authoritative and accessible. The figure geometry changes no zone mapping or +30-day calculation. `TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence, formatting, layout-invariance and source-derived text-boundary tests, plus diff --git a/web/src/dashboard/BodyZoneFigure.test.tsx b/web/src/dashboard/BodyZoneFigure.test.tsx index 78c1602..af76c38 100644 --- a/web/src/dashboard/BodyZoneFigure.test.tsx +++ b/web/src/dashboard/BodyZoneFigure.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { dashboardFixture } from '../test/dashboardFixtures' import { BODY_ZONE_SVG_MAP, BodyZoneFigure, muscleIntensityLevel, UNMAPPED_BODY_ZONE_IDS } from './BodyZoneFigure' +import { ANDROID_BODY_HEAD, ANDROID_BODY_NECK, ANDROID_BODY_REGIONS } from './androidBodyFigureGeometry' describe('silhouette BODY ZONES canonique', () => { it('mappe uniquement les huit zones anatomiquement localisables et documente les agrégats', () => { @@ -10,6 +11,55 @@ describe('silhouette BODY ZONES canonique', () => { expect(new Set(Object.values(BODY_ZONE_SVG_MAP).flat()).size).toBe(Object.values(BODY_ZONE_SVG_MAP).flat().length) }) + it('porte la tête, le cou et les onze chemins Android de chaque face', () => { + expect(ANDROID_BODY_HEAD).toEqual({ cx: 50, cy: 8.25, rx: 9.5, ry: 6.75 }) + expect(ANDROID_BODY_NECK).toBe('M44.5 13 L43 19 C46 20.5 54 20.5 57 19 L55.5 13 Z') + expect(ANDROID_BODY_REGIONS.front).toHaveLength(11) + expect(ANDROID_BODY_REGIONS.back).toHaveLength(11) + const all = [...ANDROID_BODY_REGIONS.front, ...ANDROID_BODY_REGIONS.back] + const mappedIds = Object.values(BODY_ZONE_SVG_MAP).flat() + expect(new Set(all.map((region) => region.id)).size).toBe(22) + expect(new Set(mappedIds)).toEqual(new Set(all.map((region) => region.id))) + for (const region of ANDROID_BODY_REGIONS.front) { + expect(region.id).toMatch(/^front-/) + expect(BODY_ZONE_SVG_MAP[region.zone]).toContain(region.id) + } + for (const region of ANDROID_BODY_REGIONS.back) { + expect(region.id).toMatch(/^back-/) + expect(BODY_ZONE_SVG_MAP[region.zone]).toContain(region.id) + } + expect(ANDROID_BODY_REGIONS.front.find((region) => region.zone === 'chest')?.d) + .toBe('M27 28.5 C32 24.5 40 24 49 26.5 L49 38 C42 39.5 34 38 29 34.5 Z') + expect(ANDROID_BODY_REGIONS.back.find((region) => region.zone === 'back')?.d) + .toBe('M28 28.5 C35 25 43 24.5 50 26.5 C57 24.5 65 25 72 28.5 C68 38 64 47 62 55 C56 57 44 57 38 55 C36 47 32 38 28 28.5 Z') + }) + + it('rend les deux silhouettes neutres sans données en conservant la légende', () => { + const { container } = render() + expect(screen.getByLabelText('Silhouette, vue avant')).toBeInTheDocument() + expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument() + expect(container.querySelectorAll('path.body-region')).toHaveLength(22) + expect(container.querySelectorAll('path.body-region.intensity-0')).toHaveLength(22) + expect(screen.getByLabelText('Échelle visuelle des séances sur 30 jours')) + .toHaveTextContent('0FaibleIntermédiaireMaximum observé') + }) + + it('colore les régions correctes sur chaque face sans inverser avant et arrière', () => { + const zones = [ + { zone_id: 'chest', label: 'Pectoraux', session_count: 1, occurrence_count: 1, set_count: 1 }, + { zone_id: 'back', label: 'Dos', session_count: 2, occurrence_count: 2, set_count: 2 }, + { zone_id: 'glutes', label: 'Fessiers', session_count: 3, occurrence_count: 3, set_count: 3 }, + ] + const { container } = render() + expect(container.querySelector('#front-chest-left')).toHaveClass('intensity-1') + expect(container.querySelector('#front-chest-right')).toHaveClass('intensity-1') + expect(container.querySelector('#back-trunk')).toHaveClass('intensity-2') + expect(container.querySelector('#back-glute-left')).toHaveClass('intensity-3') + expect(container.querySelector('#front-core')).toHaveClass('intensity-0') + expect(container.querySelector('#front-chest-left')?.closest('svg')).toHaveAccessibleName('Silhouette, vue avant') + expect(container.querySelector('#back-trunk')?.closest('svg')).toHaveAccessibleName('Silhouette, vue arrière') + }) + it('détermine la couleur uniquement depuis session_count et le maximum observé', () => { expect(muscleIntensityLevel(0, 4)).toBe(0) expect(muscleIntensityLevel(1, 4)).toBe(1) @@ -28,7 +78,7 @@ describe('silhouette BODY ZONES canonique', () => { expect(chest).toHaveAccessibleName('Pectoraux : 4 séances, 6 occurrences, 18 séries.') fireEvent.focus(chest) expect(screen.getByRole('status')).toHaveTextContent('Pectoraux4 séances · 6 occurrences · 18 séries') - expect(container.querySelector('#back-upper')).toHaveClass('intensity-3') + expect(container.querySelector('#back-trunk')).toHaveClass('intensity-3') }) it('rend une région absente neutre et non interactive', () => { diff --git a/web/src/dashboard/BodyZoneFigure.tsx b/web/src/dashboard/BodyZoneFigure.tsx index 921d7f9..732aee9 100644 --- a/web/src/dashboard/BodyZoneFigure.tsx +++ b/web/src/dashboard/BodyZoneFigure.tsx @@ -1,10 +1,11 @@ import { useState } from 'react' import type { WorkedZone } from '../api/dashboard' import { count } from './dashboardFormat' +import { ANDROID_BODY_HEAD, ANDROID_BODY_NECK, ANDROID_BODY_REGIONS } from './androidBodyFigureGeometry' export const BODY_ZONE_SVG_MAP = { chest: ['front-chest-left', 'front-chest-right'], - back: ['back-upper', 'back-lat-left', 'back-lat-right'], + back: ['back-trunk'], shoulders: ['front-shoulder-left', 'front-shoulder-right', 'back-shoulder-left', 'back-shoulder-right'], arms: ['front-arm-left', 'front-arm-right', 'back-arm-left', 'back-arm-right'], core: ['front-core'], @@ -57,23 +58,19 @@ export function BodyZoneFigure({ zones, views = 'both', language = 'fr' }: BodyZ type RegionProps = (zoneId: MappedBodyZoneId) => Record function BodyView({ title, side, zoneProps, language }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps; language: 'fr' | 'en' }) { - return
{title}
- - - {side === 'front' ? <> - - - - - - - : <> - - - - - - - } -
+ return
+
{title}
+ + + + {ANDROID_BODY_REGIONS[side].map((region) => + + )} + +
} diff --git a/web/src/dashboard/androidBodyFigureGeometry.ts b/web/src/dashboard/androidBodyFigureGeometry.ts new file mode 100644 index 0000000..f056fde --- /dev/null +++ b/web/src/dashboard/androidBodyFigureGeometry.ts @@ -0,0 +1,41 @@ +/* + * WHY: Android's HomeScreen.kt bodyRegions() Canvas is the visual source of + * truth for Trainlog's BODY ZONES silhouette. SVG cannot reuse Compose Path + * objects directly, so these paths transcribe its normalized coordinates + * exactly: Android fractions are multiplied by 100 for this viewBox. + * CONTRACT: preserve the Android front/back region order and zone IDs. The + * Web's session intensity and Catppuccin colors remain separate semantics. + * INVARIANT: each face has the same 11 region paths as Android; head and neck + * are Android's drawOval() and neckPath(), behind the colored regions. + */ +export const ANDROID_BODY_HEAD = { cx: 50, cy: 8.25, rx: 9.5, ry: 6.75 } as const +export const ANDROID_BODY_NECK = "M44.5 13 L43 19 C46 20.5 54 20.5 57 19 L55.5 13 Z" + +export const ANDROID_BODY_REGIONS = { + front: [ + { id: "front-shoulder-left", zone: "shoulders", d: "M48 17.5 C40 17 30 16.5 22 21.5 C18 24 18 28.5 23 30 C30 27.5 38 25 48 24.5 Z" }, + { id: "front-shoulder-right", zone: "shoulders", d: "M52 17.5 C60 17 70 16.5 78 21.5 C82 24 82 28.5 77 30 C70 27.5 62 25 52 24.5 Z" }, + { id: "front-chest-left", zone: "chest", d: "M27 28.5 C32 24.5 40 24 49 26.5 L49 38 C42 39.5 34 38 29 34.5 Z" }, + { id: "front-chest-right", zone: "chest", d: "M73 28.5 C68 24.5 60 24 51 26.5 L51 38 C58 39.5 66 38 71 34.5 Z" }, + { id: "front-arm-left", zone: "arms", d: "M21.5 25.5 C16 28.5 14.5 36.5 13 43 L10.5 56 C12 59 17 59.5 19 55.5 L25.5 30.5 Z" }, + { id: "front-arm-right", zone: "arms", d: "M78.5 25.5 C84 28.5 85.5 36.5 87 43 L89.5 56 C88 59 83 59.5 81 55.5 L74.5 30.5 Z" }, + { id: "front-core", zone: "core", d: "M30 36 C35 40 38 43 37 55 C41 58 59 58 63 55 C62 43 65 40 70 36 C60 39 40 39 30 36 Z" }, + { id: "front-thigh-left", zone: "thighs", d: "M35 56.5 C31 63 30 71 31.5 80.5 C34 83 40.5 82.5 43.5 79.5 L47.5 57 Z" }, + { id: "front-thigh-right", zone: "thighs", d: "M65 56.5 C69 63 70 71 68.5 80.5 C66 83 59.5 82.5 56.5 79.5 L52.5 57 Z" }, + { id: "front-calf-left", zone: "calves", d: "M31.5 80.5 C28.5 86 31.5 91 33.5 98 L40.5 98 C41 91 45 85.5 43.5 79.5 Z" }, + { id: "front-calf-right", zone: "calves", d: "M68.5 80.5 C71.5 86 68.5 91 66.5 98 L59.5 98 C59 91 55 85.5 56.5 79.5 Z" }, + ], + back: [ + { id: "back-shoulder-left", zone: "shoulders", d: "M48 17.5 C40 17 30 16.5 22 21.5 C18 24 18 28.5 23 30 C30 27.5 38 25 48 24.5 Z" }, + { id: "back-shoulder-right", zone: "shoulders", d: "M52 17.5 C60 17 70 16.5 78 21.5 C82 24 82 28.5 77 30 C70 27.5 62 25 52 24.5 Z" }, + { id: "back-trunk", zone: "back", d: "M28 28.5 C35 25 43 24.5 50 26.5 C57 24.5 65 25 72 28.5 C68 38 64 47 62 55 C56 57 44 57 38 55 C36 47 32 38 28 28.5 Z" }, + { id: "back-arm-left", zone: "arms", d: "M21.5 25.5 C16 28.5 14.5 36.5 13 43 L10.5 56 C12 59 17 59.5 19 55.5 L25.5 30.5 Z" }, + { id: "back-arm-right", zone: "arms", d: "M78.5 25.5 C84 28.5 85.5 36.5 87 43 L89.5 56 C88 59 83 59.5 81 55.5 L74.5 30.5 Z" }, + { id: "back-glute-left", zone: "glutes", d: "M37 53.5 C31 57 32 64 39 66.5 C43 67.5 47.5 64.5 49 59.5 L49 55 Z" }, + { id: "back-glute-right", zone: "glutes", d: "M63 53.5 C69 57 68 64 61 66.5 C57 67.5 52.5 64.5 51 59.5 L51 55 Z" }, + { id: "back-thigh-left", zone: "thighs", d: "M35 56.5 C31 63 30 71 31.5 80.5 C34 83 40.5 82.5 43.5 79.5 L47.5 57 Z" }, + { id: "back-thigh-right", zone: "thighs", d: "M65 56.5 C69 63 70 71 68.5 80.5 C66 83 59.5 82.5 56.5 79.5 L52.5 57 Z" }, + { id: "back-calf-left", zone: "calves", d: "M31.5 80.5 C28.5 86 31.5 91 33.5 98 L40.5 98 C41 91 45 85.5 43.5 79.5 Z" }, + { id: "back-calf-right", zone: "calves", d: "M68.5 80.5 C71.5 86 68.5 91 66.5 98 L59.5 98 C59 91 55 85.5 56.5 79.5 Z" }, + ], +} as const diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 7273f2f..d1e30e0 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -549,7 +549,7 @@ h2 { gap: 2rem; } .analysis-zones .body-views svg { - height: clamp(22rem, 42vw, 31rem); + width: min(100%, 20rem); } .analysis-zones .muscle-legend { margin-top: 0.8rem; @@ -1196,7 +1196,7 @@ h2 { flex: 1 1 8rem; } .analysis-zones .body-views svg { - height: clamp(16rem, 58vw, 23rem); + width: min(100%, 16rem); } .analysis-chart svg { height: 14rem; @@ -2826,21 +2826,22 @@ h2 { } .body-views svg { display: block; - width: 100%; - height: 10.5rem; + width: min(100%, 11.75rem); + height: auto; + /* Android HomeScreen uses a 236dp Canvas with a roughly 168dp half-panel width. */ + aspect-ratio: 168 / 236; + margin-inline: auto; overflow: visible; } .tile-large .body-views svg { - height: 16.5rem; + width: min(100%, 14rem); } -.body-outline { +.body-figure-base { fill: var(--surface-0); - stroke: var(--surface-2); - stroke-width: 1.2; } .body-region { stroke: var(--base); - stroke-width: 1.4; + stroke-width: 1.25; vector-effect: non-scaling-stroke; transition: fill 140ms ease, @@ -3121,7 +3122,7 @@ h2 { } .body-views svg, .tile-large .body-views svg { - height: 11rem; + width: min(100%, 8rem); } .status-footer { grid-template-columns: 1fr 1fr;