refactor: extract RingGraph, StatsRingGraph, MacroBreakdown components

Consolidate 6 duplicated instances of the SVG arc ring pattern into a
composable component hierarchy: RingGraph (base ring), StatsRingGraph
(ring with target markers), and MacroBreakdown (3 rings + kcal + detail
table). Removes ~400 lines of duplicated SVG/CSS from FoodSearch,
nutrition page, meals page, stats page, food detail page, and recipe
NutritionSummary.
This commit is contained in:
2026-04-08 22:21:21 +02:00
parent 1b7eb4eb44
commit c2af12c8d7
10 changed files with 420 additions and 602 deletions
+11 -172
View File
@@ -3,6 +3,7 @@
import { browser } from '$app/environment';
import { Heart, ExternalLink, ScanBarcode, X } from '@lucide/svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import MacroBreakdown from './MacroBreakdown.svelte';
/**
* @type {{
@@ -30,15 +31,6 @@
const isEn = $derived(lang === 'en');
const btnLabel = $derived(confirmLabel ?? t('log_food', lang));
// SVG ring constants
const RADIUS = 28;
const ARC_DEGREES = 300;
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
const ARC_ROTATE = 120;
function strokeOffset(percent) {
return ARC_LENGTH - (Math.min(percent, 100) / 100) * ARC_LENGTH;
}
// --- Search state ---
let query = $state('');
let results = $state(initialResults ?? []);
@@ -126,21 +118,6 @@
};
});
const macroPercent = $derived.by(() => {
if (!selected?.per100g) return { protein: 0, fat: 0, carbs: 0 };
const n = selected.per100g;
const proteinCal = (n.protein ?? 0) * 4;
const fatCal = (n.fat ?? 0) * 9;
const carbsCal = (n.carbs ?? 0) * 4;
const total = proteinCal + fatCal + carbsCal;
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
return {
protein: Math.round(proteinCal / total * 100),
fat: Math.round(fatCal / total * 100),
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
};
});
function confirm() {
if (!selected) return;
const grams = resolveGrams();
@@ -195,11 +172,7 @@
}
}
function fmt(v) {
if (v >= 100) return Math.round(v).toString();
if (v >= 10) return v.toFixed(1);
return v.toFixed(1);
}
function sourceLabel(source) {
if (source === 'bls') return 'BLS';
@@ -518,63 +491,15 @@
{/if}
{#if previewNutrients}
<!-- Calorie headline -->
<div class="fs-detail-cal">
<span class="fs-detail-cal-num">{previewNutrients.calories}</span>
<span class="fs-detail-cal-unit">kcal</span>
</div>
<!-- Macro rings -->
<div class="fs-detail-macros">
{#each [
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'fs-ring-protein', grams: previewNutrients.protein },
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', cls: 'fs-ring-fat', grams: previewNutrients.fat },
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'fs-ring-carbs', grams: previewNutrients.carbs },
] as macro (macro.cls)}
<div class="fs-detail-macro">
<svg width="72" height="72" viewBox="0 0 70 70">
<circle class="fs-ring-bg" cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
transform="rotate({ARC_ROTATE} 35 35)" />
<circle class="fs-ring-fill {macro.cls}" cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
stroke-dashoffset={strokeOffset(macro.pct)}
transform="rotate({ARC_ROTATE} 35 35)" />
<text class="fs-ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="fs-detail-macro-label">{macro.label}</span>
<span class="fs-detail-macro-val">{fmt(macro.grams)}g</span>
</div>
{/each}
</div>
<!-- Macro detail rows -->
<div class="fs-detail-rows">
<div class="fs-detail-row">
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
<span>{fmt(previewNutrients.protein)} g</span>
</div>
<div class="fs-detail-row">
<span>{isEn ? 'Fat' : 'Fett'}</span>
<span>{fmt(previewNutrients.fat)} g</span>
</div>
<div class="fs-detail-row sub">
<span>{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
<span>{fmt(previewNutrients.saturatedFat)} g</span>
</div>
<div class="fs-detail-row">
<span>{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
<span>{fmt(previewNutrients.carbs)} g</span>
</div>
<div class="fs-detail-row sub">
<span>{isEn ? 'Sugars' : 'Zucker'}</span>
<span>{fmt(previewNutrients.sugars)} g</span>
</div>
<div class="fs-detail-row">
<span>{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
<span>{fmt(previewNutrients.fiber)} g</span>
</div>
</div>
<MacroBreakdown
calories={previewNutrients.calories}
protein={previewNutrients.protein}
fat={previewNutrients.fat}
carbs={previewNutrients.carbs}
saturatedFat={previewNutrients.saturatedFat}
sugars={previewNutrients.sugars}
fiber={previewNutrients.fiber}
/>
{/if}
<div class="fs-actions">
@@ -923,92 +848,6 @@
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.fs-detail-cal {
text-align: center;
margin: 0.25rem 0 0.25rem;
}
.fs-detail-cal-num {
font-size: 2.2rem;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1;
}
.fs-detail-cal-unit {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-left: 0.2rem;
}
.fs-detail-macros {
display: flex;
justify-content: space-around;
margin: 0.25rem 0 0.5rem;
}
.fs-detail-macro {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
flex: 1;
}
.fs-detail-macro-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-primary);
text-align: center;
}
.fs-detail-macro-val {
font-size: 0.72rem;
color: var(--color-text-tertiary);
}
.fs-ring-bg {
fill: none;
stroke: var(--color-border);
stroke-width: 5;
stroke-linecap: round;
}
.fs-ring-fill {
fill: none;
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.fs-ring-text {
font-size: 14px;
font-weight: 700;
fill: currentColor;
text-anchor: middle;
dominant-baseline: central;
}
.fs-ring-protein { stroke: var(--nord14); }
.fs-ring-fat { stroke: var(--nord12); }
.fs-ring-carbs { stroke: var(--nord9); }
.fs-detail-rows {
background: var(--color-surface);
border-radius: 10px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
}
.fs-detail-row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text-primary);
}
.fs-detail-row:last-child {
border-bottom: none;
}
.fs-detail-row.sub span:first-child {
padding-left: 0.75rem;
color: var(--color-text-tertiary);
font-size: 0.8rem;
}
.fs-detail-row span:last-child {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* ── Buttons ── */
.fs-actions {
@@ -0,0 +1,161 @@
<script>
import { detectFitnessLang } from '$lib/js/fitnessI18n';
import { page } from '$app/stores';
import RingGraph from './RingGraph.svelte';
/**
* @type {{
* calories?: number,
* protein: number,
* fat: number,
* carbs: number,
* saturatedFat?: number,
* sugars?: number,
* fiber?: number,
* showCalories?: boolean,
* showDetailRows?: boolean,
* }}
*/
let {
calories = 0,
protein = 0,
fat = 0,
carbs = 0,
saturatedFat = 0,
sugars = 0,
fiber = 0,
showCalories = true,
showDetailRows = true,
} = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const isEn = $derived(lang === 'en');
const macroPercent = $derived.by(() => {
const proteinCal = protein * 4;
const fatCal = fat * 9;
const carbsCal = carbs * 4;
const total = proteinCal + fatCal + carbsCal;
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
return {
protein: Math.round(proteinCal / total * 100),
fat: Math.round(fatCal / total * 100),
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
};
});
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (v >= 100) return Math.round(v).toString();
return v.toFixed(1);
}
const macros = $derived([
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', color: 'var(--nord14)', grams: protein },
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', color: 'var(--nord12)', grams: fat },
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', color: 'var(--nord9)', grams: carbs },
]);
</script>
<div class="macro-breakdown">
{#if showCalories}
<div class="mb-cal">
<span class="mb-cal-num">{Math.round(calories)}</span>
<span class="mb-cal-unit">kcal</span>
</div>
{/if}
<div class="mb-rings">
{#each macros as macro (macro.color)}
<RingGraph
percent={macro.pct}
color={macro.color}
label={macro.label}
sublabel="{fmt(macro.grams)}g"
/>
{/each}
</div>
{#if showDetailRows}
<div class="mb-rows">
<div class="mb-row">
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
<span>{fmt(protein)} g</span>
</div>
<div class="mb-row">
<span>{isEn ? 'Fat' : 'Fett'}</span>
<span>{fmt(fat)} g</span>
</div>
<div class="mb-row sub">
<span>{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
<span>{fmt(saturatedFat)} g</span>
</div>
<div class="mb-row">
<span>{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
<span>{fmt(carbs)} g</span>
</div>
<div class="mb-row sub">
<span>{isEn ? 'Sugars' : 'Zucker'}</span>
<span>{fmt(sugars)} g</span>
</div>
<div class="mb-row">
<span>{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
<span>{fmt(fiber)} g</span>
</div>
</div>
{/if}
</div>
<style>
.macro-breakdown {
display: flex;
flex-direction: column;
}
.mb-cal {
text-align: center;
margin-bottom: 0.25rem;
}
.mb-cal-num {
font-size: 2.2rem;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1;
}
.mb-cal-unit {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-left: 0.2rem;
}
.mb-rings {
display: flex;
justify-content: space-around;
margin: 0.25rem 0 0.5rem;
}
.mb-rows {
background: var(--color-surface);
border-radius: 10px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
}
.mb-row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text-primary);
}
.mb-row:last-child {
border-bottom: none;
}
.mb-row.sub span:first-child {
padding-left: 0.75rem;
color: var(--color-text-tertiary);
font-size: 0.8rem;
}
.mb-row span:last-child {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,95 @@
<script module>
export const RADIUS = 28;
export const ARC_DEGREES = 300;
export const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
export const ARC_ROTATE = 120;
/** @param {number} percent */
export function strokeOffset(percent) {
return ARC_LENGTH - (Math.min(percent, 100) / 100) * ARC_LENGTH;
}
</script>
<script>
/**
* @type {{
* percent: number,
* color: string,
* label?: string,
* sublabel?: string,
* extra?: import('svelte').Snippet,
* }}
*/
let {
percent = 0,
color = 'currentColor',
label = '',
sublabel = '',
extra = undefined,
} = $props();
</script>
<div class="ring-wrap">
<svg class="ring-svg" viewBox="0 0 70 70">
<circle class="ring-bg" cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
transform="rotate({ARC_ROTATE} 35 35)" />
<circle class="ring-fill" cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
stroke-dashoffset={strokeOffset(percent)}
transform="rotate({ARC_ROTATE} 35 35)"
stroke={color} />
{@render extra?.()}
<text class="ring-text" x="35" y="35">{percent}%</text>
</svg>
{#if label}
<span class="ring-label">{label}</span>
{/if}
{#if sublabel}
<span class="ring-sublabel">{sublabel}</span>
{/if}
</div>
<style>
.ring-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
flex: 1;
}
.ring-svg {
width: 72px;
height: 72px;
overflow: visible;
}
.ring-bg {
fill: none;
stroke: var(--color-border);
stroke-width: 5;
stroke-linecap: round;
}
.ring-fill {
fill: none;
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.ring-text {
font-size: 14px;
font-weight: 700;
fill: currentColor;
text-anchor: middle;
dominant-baseline: central;
}
.ring-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-primary);
text-align: center;
}
.ring-sublabel {
font-size: 0.72rem;
color: var(--color-text-tertiary);
}
</style>
@@ -0,0 +1,78 @@
<script>
import RingGraph from './RingGraph.svelte';
import { RADIUS, ARC_DEGREES, ARC_ROTATE } from './RingGraph.svelte';
/**
* @type {{
* percent: number,
* color: string,
* label?: string,
* sublabel?: string,
* target?: number,
* markerColor?: string,
* }}
*/
let {
percent = 0,
color = 'currentColor',
label = '',
sublabel = '',
target = undefined,
markerColor = 'var(--color-text-secondary)',
} = $props();
/**
* Get SVG coordinates for a triangle marker + label at a given percentage on the arc.
* @param {number} pct
*/
function targetMarkerPos(pct) {
const angleDeg = ARC_ROTATE + (pct / 100) * ARC_DEGREES;
const angleRad = (angleDeg * Math.PI) / 180;
const outerR = RADIUS + 7;
const cx = 35 + outerR * Math.cos(angleRad);
const cy = 35 + outerR * Math.sin(angleRad);
// Label positioning: primarily radial, with tangential nudge near 50%
const closeness = 1 - Math.abs(pct - 50) / 50;
const highBonus = pct > 50 && closeness < 0.4 ? 4 : 0;
const midBump = Math.max(0, 1 - Math.abs(closeness - 0.2) / 0.3) * 4;
const labelR = outerR + 17 + highBonus + midBump - closeness * closeness * 14;
const tOff = closeness * closeness * 14;
const dir = pct < 50 ? -1 : 1;
const tangentRad = angleRad + dir * Math.PI / 2;
const lx = 35 + labelR * Math.cos(angleRad) + tOff * Math.cos(tangentRad);
const ly = 35 + labelR * Math.sin(angleRad) + tOff * Math.sin(tangentRad);
return { cx, cy, lx, ly, angleDeg };
}
</script>
<RingGraph {percent} {color} {label} {sublabel}>
{#snippet extra()}
{#if target != null}
{@const pos = targetMarkerPos(target)}
<path
fill={markerColor}
opacity="0.85"
stroke={markerColor}
stroke-width="0.8"
stroke-linejoin="round"
d="M{pos.cx},{pos.cy - 3.5}L{pos.cx - 3},{pos.cy + 2.5}L{pos.cx + 3},{pos.cy + 2.5}Z"
transform="rotate({pos.angleDeg - 90} {pos.cx} {pos.cy})"
/>
<text
class="target-label"
fill={markerColor}
x={pos.lx}
y={pos.ly}
text-anchor="middle"
dominant-baseline="central"
>{target}%</text>
{/if}
{/snippet}
</RingGraph>
<style>
.target-label {
font-size: 7px;
font-weight: 700;
}
</style>
@@ -1,5 +1,6 @@
<script>
import { createNutritionCalculator } from '$lib/js/nutrition.svelte';
import RingGraph from '$lib/components/fitness/RingGraph.svelte';
let { flatIngredients, nutritionMappings, sectionNames, referencedNutrition, multiplier, portions, isEnglish, actions } = $props();
@@ -70,19 +71,7 @@
return value.toFixed(1);
}
// SVG arc parameters — 300° arc with 60° gap at bottom
const RADIUS = 28;
const ARC_DEGREES = 300;
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
// Arc starts at the left side: rotate so the gap is centered at the bottom
// 0° in SVG circle = 3 o'clock. We want the arc to start at ~210° (7 o'clock)
// and end at ~150° (5 o'clock), leaving a 60° gap at bottom center.
const ARC_ROTATE = 120; // rotate the starting point: -90 (top) + 210 offset → start at left
/** @param {number} percent */
function strokeOffset(percent) {
return ARC_LENGTH - (percent / 100) * ARC_LENGTH;
}
</script>
<style>
@@ -101,40 +90,12 @@
justify-content: space-around;
margin: 0.5rem 0;
}
.macro-ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
.macro-rings :global(.ring-svg) {
width: 90px;
height: 90px;
}
.ring-bg {
fill: none;
stroke: var(--color-border, #e5e5e5);
stroke-width: 5;
stroke-linecap: round;
}
.ring-fill {
fill: none;
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.ring-text {
font-size: 14px;
font-weight: 700;
fill: currentColor;
text-anchor: middle;
dominant-baseline: central;
}
.ring-protein { stroke: var(--nord14, #a3be8c); }
.ring-fat { stroke: var(--nord12, #d08770); }
.ring-carbs { stroke: var(--nord9, #81a1c1); }
.macro-label {
.macro-rings :global(.ring-label) {
font-size: 0.85rem;
font-weight: 600;
text-align: center;
}
.details-toggle-row {
@@ -194,29 +155,15 @@
<div class="nutrition-summary">
<div class="macro-rings">
{#each [
{ pct: macroPercent.protein, label: labels.protein, cls: 'ring-protein' },
{ pct: macroPercent.fat, label: labels.fat, cls: 'ring-fat' },
{ pct: macroPercent.carbs, label: labels.carbs, cls: 'ring-carbs' },
] as macro}
<div class="macro-ring">
<svg width="90" height="90" viewBox="0 0 70 70">
<circle
class="ring-bg"
cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
transform="rotate({ARC_ROTATE} 35 35)"
/>
<circle
class="ring-fill {macro.cls}"
cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
stroke-dashoffset={strokeOffset(macro.pct)}
transform="rotate({ARC_ROTATE} 35 35)"
/>
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="macro-label">{macro.label}</span>
</div>
{ pct: macroPercent.protein, label: labels.protein, color: 'var(--nord14)' },
{ pct: macroPercent.fat, label: labels.fat, color: 'var(--nord12)' },
{ pct: macroPercent.carbs, label: labels.carbs, color: 'var(--nord9)' },
] as macro (macro.color)}
<RingGraph
percent={macro.pct}
color={macro.color}
label={macro.label}
/>
{/each}
</div>