feat: add muscle visualization to exercise detail page
All checks were successful
CI / update (push) Successful in 3m42s
All checks were successful
CI / update (push) Successful in 3m42s
New MuscleMap component highlights primary muscles at full opacity and secondary muscles at 40% using the existing body SVG diagrams. Only renders front/back views that have active muscles. Responsive layout: centered inline on mobile, sticky sidebar on desktop.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
198
src/lib/components/fitness/MuscleMap.svelte
Normal file
198
src/lib/components/fitness/MuscleMap.svelte
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
|
||||||
|
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
|
||||||
|
|
||||||
|
let { primaryGroups = [], secondaryGroups = [], lang = 'en' } = $props();
|
||||||
|
|
||||||
|
const isEn = $derived(lang === 'en');
|
||||||
|
|
||||||
|
const FRONT_MAP = {
|
||||||
|
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
|
||||||
|
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
|
||||||
|
'chest': { groups: ['pectorals'], label: { en: 'Chest', de: 'Brust' } },
|
||||||
|
'biceps': { groups: ['biceps', 'brachioradialis'], label: { en: 'Biceps', de: 'Bizeps' } },
|
||||||
|
'forearms': { groups: ['forearms'], label: { en: 'Forearms', de: 'Unterarme' } },
|
||||||
|
'abdominals': { groups: ['abdominals'], label: { en: 'Abs', de: 'Bauchmuskeln' } },
|
||||||
|
'obliques': { groups: ['obliques'], label: { en: 'Obliques', de: 'Seitl. Bauch' } },
|
||||||
|
'quads': { groups: ['quadriceps', 'hip flexors'], label: { en: 'Quads', de: 'Quadrizeps' } },
|
||||||
|
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACK_MAP = {
|
||||||
|
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
|
||||||
|
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
|
||||||
|
'rear-shoulders': { groups: ['rear deltoids', 'rotator cuff'], label: { en: 'Rear Delts', de: 'Hint. Schultern' } },
|
||||||
|
'lats': { groups: ['lats'], label: { en: 'Lats', de: 'Latissimus' } },
|
||||||
|
'triceps': { groups: ['triceps'], label: { en: 'Triceps', de: 'Trizeps' } },
|
||||||
|
'forearms': { groups: ['forearms'], label: { en: 'Forearms', de: 'Unterarme' } },
|
||||||
|
'lowerback': { groups: ['erector spinae'], label: { en: 'Lower Back', de: 'Rückenstrecker' } },
|
||||||
|
'glutes': { groups: ['glutes'], label: { en: 'Glutes', de: 'Gesäss' } },
|
||||||
|
'hamstrings': { groups: ['hamstrings'], label: { en: 'Hamstrings', de: 'Beinbeuger' } },
|
||||||
|
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const primarySet = $derived(new Set(primaryGroups));
|
||||||
|
const secondarySet = $derived(new Set(secondaryGroups));
|
||||||
|
|
||||||
|
function regionState(groups) {
|
||||||
|
if (groups.some(g => primarySet.has(g))) return 'primary';
|
||||||
|
if (groups.some(g => secondarySet.has(g))) return 'secondary';
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionFill(groups) {
|
||||||
|
const state = regionState(groups);
|
||||||
|
if (state === 'primary') return 'var(--color-primary)';
|
||||||
|
if (state === 'secondary') return 'var(--color-primary-secondary, color-mix(in srgb, var(--color-primary) 40%, var(--color-bg-tertiary)))';
|
||||||
|
return 'var(--color-bg-tertiary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectFills(svgStr, map) {
|
||||||
|
let result = svgStr;
|
||||||
|
for (const [svgId, region] of Object.entries(map)) {
|
||||||
|
const fill = regionFill(region.groups);
|
||||||
|
const re = new RegExp(`(<g\\s+id="${svgId}")([^>]*>)`);
|
||||||
|
result = result.replace(re, `$1 style="--region-fill: ${fill};"$2`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontSvg = $derived(injectFills(frontSvgRaw, FRONT_MAP));
|
||||||
|
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
|
||||||
|
|
||||||
|
let hovered = $state(null);
|
||||||
|
let hoveredSide = $state('front');
|
||||||
|
const hoveredLabel = $derived.by(() => {
|
||||||
|
if (!hovered) return null;
|
||||||
|
const state = regionState(hovered.groups);
|
||||||
|
const label = isEn ? hovered.label.en : hovered.label.de;
|
||||||
|
const suffix = state === 'primary' ? '' : state === 'secondary' ? (isEn ? ' (secondary)' : ' (sekundär)') : '';
|
||||||
|
return label + suffix;
|
||||||
|
});
|
||||||
|
|
||||||
|
let frontEl = $state(null);
|
||||||
|
let backEl = $state(null);
|
||||||
|
|
||||||
|
function setupEvents(container, map, side) {
|
||||||
|
if (!container) return;
|
||||||
|
container.addEventListener('mouseover', (e) => {
|
||||||
|
const g = e.target.closest('g[id]');
|
||||||
|
if (g && map[g.id]) {
|
||||||
|
hovered = map[g.id];
|
||||||
|
hoveredSide = side;
|
||||||
|
g.classList.add('highlighted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.addEventListener('mouseout', (e) => {
|
||||||
|
const g = e.target.closest('g[id]');
|
||||||
|
if (g) g.classList.remove('highlighted');
|
||||||
|
});
|
||||||
|
container.addEventListener('mouseleave', () => { hovered = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setupEvents(frontEl, FRONT_MAP, 'front');
|
||||||
|
setupEvents(backEl, BACK_MAP, 'back');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if any muscles are on front/back to decide which to show
|
||||||
|
function hasActiveRegions(map) {
|
||||||
|
return Object.values(map).some(r => regionState(r.groups) !== 'inactive');
|
||||||
|
}
|
||||||
|
const hasFront = $derived(hasActiveRegions(FRONT_MAP));
|
||||||
|
const hasBack = $derived(hasActiveRegions(BACK_MAP));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="muscle-map">
|
||||||
|
<div class="body-figures">
|
||||||
|
{#if hasFront}
|
||||||
|
<div class="figure">
|
||||||
|
<div class="svg-wrap" bind:this={frontEl}>
|
||||||
|
{@html frontSvg}
|
||||||
|
</div>
|
||||||
|
<span class="side-label">{isEn ? 'Front' : 'Vorne'}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if hasBack}
|
||||||
|
<div class="figure">
|
||||||
|
<div class="svg-wrap" bind:this={backEl}>
|
||||||
|
{@html backSvg}
|
||||||
|
</div>
|
||||||
|
<span class="side-label">{isEn ? 'Back' : 'Hinten'}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hoveredLabel}
|
||||||
|
<div class="hover-label">{hoveredLabel}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.muscle-map {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-figures {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-wrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-wrap :global(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-wrap :global(g:not(#body):not(#head) path) {
|
||||||
|
fill: var(--region-fill, var(--color-bg-tertiary));
|
||||||
|
stroke: var(--color-text-primary);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
transition: fill 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-wrap :global(g.highlighted:not(#body):not(#head) path) {
|
||||||
|
stroke: var(--color-primary);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-wrap :global(#body path),
|
||||||
|
.svg-wrap :global(#body line) {
|
||||||
|
stroke: var(--color-text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||||
const s = $derived(fitnessSlugs(lang));
|
const s = $derived(fitnessSlugs(lang));
|
||||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||||
|
import MuscleMap from '$lib/components/fitness/MuscleMap.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -128,7 +129,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if activeTab === 'about'}
|
{#if activeTab === 'about'}
|
||||||
<div class="tab-content">
|
<div class="tab-content about-layout">
|
||||||
|
<div class="about-main">
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span class="tag body-part">{exercise?.localBodyPart}</span>
|
<span class="tag body-part">{exercise?.localBodyPart}</span>
|
||||||
@@ -136,10 +138,14 @@
|
|||||||
<span class="tag target">{exercise?.localTarget}</span>
|
<span class="tag target">{exercise?.localTarget}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Muscle pills -->
|
<!-- Muscle map (mobile only — shown inline) -->
|
||||||
{#if exercise?.localSecondaryMuscles?.length || exercise?.localTarget}
|
{#if exercise?.localSecondaryMuscles?.length || exercise?.localTarget}
|
||||||
<div class="muscle-section">
|
<div class="muscle-section-mobile">
|
||||||
<h3>{lang === 'en' ? 'Muscles' : 'Muskeln'}</h3>
|
<MuscleMap
|
||||||
|
primaryGroups={[exercise?.target].filter(Boolean)}
|
||||||
|
secondaryGroups={exercise?.secondaryMuscles ?? []}
|
||||||
|
{lang}
|
||||||
|
/>
|
||||||
<div class="muscle-pills">
|
<div class="muscle-pills">
|
||||||
{#if exercise?.localTarget}
|
{#if exercise?.localTarget}
|
||||||
<span class="muscle-pill primary">{exercise.localTarget}</span>
|
<span class="muscle-pill primary">{exercise.localTarget}</span>
|
||||||
@@ -184,6 +190,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Muscle map sidebar (desktop only) -->
|
||||||
|
{#if exercise?.localSecondaryMuscles?.length || exercise?.localTarget}
|
||||||
|
<aside class="muscle-sidebar">
|
||||||
|
<MuscleMap
|
||||||
|
primaryGroups={[exercise?.target].filter(Boolean)}
|
||||||
|
secondaryGroups={exercise?.secondaryMuscles ?? []}
|
||||||
|
{lang}
|
||||||
|
/>
|
||||||
|
<div class="muscle-pills">
|
||||||
|
{#if exercise?.localTarget}
|
||||||
|
<span class="muscle-pill primary">{exercise.localTarget}</span>
|
||||||
|
{/if}
|
||||||
|
{#each exercise?.localSecondaryMuscles ?? [] as muscle}
|
||||||
|
<span class="muscle-pill secondary">{muscle}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
{#if history.length === 0}
|
{#if history.length === 0}
|
||||||
@@ -325,14 +351,53 @@
|
|||||||
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
|
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
|
||||||
.target { background: rgba(208, 135, 112, 0.2); color: var(--nord12); }
|
.target { background: rgba(208, 135, 112, 0.2); color: var(--nord12); }
|
||||||
|
|
||||||
/* Muscle pills */
|
/* About layout — two-column on wide screens */
|
||||||
.muscle-section {
|
.about-layout {
|
||||||
margin-bottom: 0.25rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.about-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.muscle-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.muscle-section-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.about-layout {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.muscle-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.muscle-section-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Muscle pills */
|
||||||
.muscle-pills {
|
.muscle-pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.muscle-pill {
|
.muscle-pill {
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.2rem 0.55rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user