fix(web): align body figures with android silhouettes

This commit is contained in:
fy59 2026-10-01 22:10:30 +02:00
parent 469a256715
commit c2935b81ee
7 changed files with 142 additions and 39 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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(<BodyZoneFigure zones={[]} />)
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(<BodyZoneFigure zones={zones} />)
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', () => {

View file

@ -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<string, unknown>
function BodyView({ title, side, zoneProps, language }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps; language: 'fr' | 'en' }) {
return <figure><figcaption>{title}</figcaption><svg viewBox="0 0 120 260" role="group" aria-label={language === 'fr' ? `Silhouette, vue ${title.toLowerCase()}` : `Silhouette, ${title.toLowerCase()} view`}>
<path className="body-outline" d="M60 5C49 5 43 13 44 24c1 10 7 17 16 17s15-7 16-17C77 13 71 5 60 5Z" />
<path className="body-outline" d="M53 40v7c-13 2-21 8-25 20l-9 55c-1 7 2 11 8 12 5 1 9-3 10-9l7-43 1 49c0 8-5 17-7 27l-4 38 9 52c1 6 6 9 11 7 4-1 6-5 6-10v-43l4-28 4 28v43c0 5 2 9 6 10 5 2 10-1 11-7l9-52-4-38c-2-10-7-19-7-27l1-49 7 43c1 6 5 10 10 9 6-1 9-5 8-12l-9-55c-4-12-12-18-25-20v-7Z" />
{side === 'front' ? <>
<ellipse id="front-shoulder-left" {...zoneProps('shoulders')} cx="37" cy="57" rx="10" ry="12" /><ellipse id="front-shoulder-right" {...zoneProps('shoulders')} cx="83" cy="57" rx="10" ry="12" />
<path id="front-chest-left" {...zoneProps('chest')} d="M43 58 Q52 53 58 59 L57 85 Q47 85 42 77Z" /><path id="front-chest-right" {...zoneProps('chest')} d="M77 58 Q68 53 62 59 L63 85 Q73 85 78 77Z" />
<path id="front-arm-left" {...zoneProps('arms')} d="M28 61 Q35 58 41 64 L34 130 23 128Z" /><path id="front-arm-right" {...zoneProps('arms')} d="M92 61 Q85 58 79 64 L86 130 97 128Z" />
<path id="front-core" {...zoneProps('core')} d="M46 88 Q60 94 74 88 L76 135 Q60 145 44 135Z" />
<path id="front-thigh-left" {...zoneProps('thighs')} d="M45 139 Q54 143 59 140 L56 198 43 198Z" /><path id="front-thigh-right" {...zoneProps('thighs')} d="M75 139 Q66 143 61 140 L64 198 77 198Z" />
<path id="front-calf-left" {...zoneProps('calves')} d="M43 201H56L54 247H46Z" /><path id="front-calf-right" {...zoneProps('calves')} d="M64 201H77L74 247H66Z" />
</> : <>
<ellipse id="back-shoulder-left" {...zoneProps('shoulders')} cx="37" cy="57" rx="10" ry="12" /><ellipse id="back-shoulder-right" {...zoneProps('shoulders')} cx="83" cy="57" rx="10" ry="12" />
<path id="back-upper" {...zoneProps('back')} d="M43 57 Q60 48 77 57 L73 83 Q60 91 47 83Z" /><path id="back-lat-left" {...zoneProps('back')} d="M43 82L58 89 55 127 43 135Z" /><path id="back-lat-right" {...zoneProps('back')} d="M77 82L62 89 65 127 77 135Z" />
<path id="back-arm-left" {...zoneProps('arms')} d="M28 61 Q35 58 41 64 L34 130 23 128Z" /><path id="back-arm-right" {...zoneProps('arms')} d="M92 61 Q85 58 79 64 L86 130 97 128Z" />
<path id="back-glute-left" {...zoneProps('glutes')} d="M44 132 Q53 127 59 136 L57 157 Q47 160 42 151Z" /><path id="back-glute-right" {...zoneProps('glutes')} d="M76 132 Q67 127 61 136 L63 157 Q73 160 78 151Z" />
<path id="back-thigh-left" {...zoneProps('thighs')} d="M43 159Q52 162 58 158L56 201H43Z" /><path id="back-thigh-right" {...zoneProps('thighs')} d="M77 159Q68 162 62 158L64 201H77Z" />
<path id="back-calf-left" {...zoneProps('calves')} d="M43 203H56L54 247H46Z" /><path id="back-calf-right" {...zoneProps('calves')} d="M64 203H77L74 247H66Z" />
</>}
</svg></figure>
return <figure>
<figcaption>{title}</figcaption>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
role="group"
aria-label={language === 'fr' ? `Silhouette, vue ${title.toLowerCase()}` : `Silhouette, ${title.toLowerCase()} view`}
>
<ellipse className="body-figure-base" {...ANDROID_BODY_HEAD} />
<path className="body-figure-base" d={ANDROID_BODY_NECK} />
{ANDROID_BODY_REGIONS[side].map((region) =>
<path key={region.id} id={region.id} d={region.d} {...zoneProps(region.zone)} />
)}
</svg>
</figure>
}

View file

@ -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

View file

@ -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;