refactor: extract RingGraph, StatsRingGraph, MacroBreakdown components
CI / update (push) Successful in 3m28s
CI / update (push) Successful in 3m28s
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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
|
||||
import MacroBreakdown from '$lib/components/fitness/MacroBreakdown.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
import { getDRI, NUTRIENT_META } from '$lib/data/dailyReferenceIntake';
|
||||
@@ -626,25 +627,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
function cmMacroPercent(meal) {
|
||||
const { per100g } = aggregateMealPer100g(meal);
|
||||
const proteinCal = per100g.protein * 4;
|
||||
const fatCal = per100g.fat * 9;
|
||||
const carbsCal = per100g.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 fmtNutrient(v) {
|
||||
if (v == null || isNaN(v)) return '0';
|
||||
if (v >= 100) return Math.round(v).toString();
|
||||
return v.toFixed(1);
|
||||
}
|
||||
|
||||
async function loadCustomMeals() {
|
||||
if (customMealsLoaded) return;
|
||||
@@ -993,7 +976,6 @@
|
||||
|
||||
{#snippet cmDetailScreen(meal, logFn)}
|
||||
{@const preview = cmPreview(meal)}
|
||||
{@const mp = cmMacroPercent(meal)}
|
||||
<div class="cm-detail">
|
||||
<div class="cm-detail-header">
|
||||
<span class="cm-detail-name">{meal.name}</span>
|
||||
@@ -1019,63 +1001,15 @@
|
||||
<span class="cm-detail-hint">= {Math.round(preview.grams)}g</span>
|
||||
{/if}
|
||||
|
||||
<!-- Calorie headline -->
|
||||
<div class="cm-detail-cal">
|
||||
<span class="cm-detail-cal-num">{preview.calories}</span>
|
||||
<span class="cm-detail-cal-unit">kcal</span>
|
||||
</div>
|
||||
|
||||
<!-- Macro rings -->
|
||||
<div class="cm-detail-macros">
|
||||
{#each [
|
||||
{ pct: mp.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'ring-protein', grams: preview.protein },
|
||||
{ pct: mp.fat, label: isEn ? 'Fat' : 'Fett', cls: 'ring-fat', grams: preview.fat },
|
||||
{ pct: mp.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'ring-carbs', grams: preview.carbs },
|
||||
] as macro}
|
||||
<div class="cm-detail-macro">
|
||||
<svg width="72" height="72" 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="cm-detail-macro-label">{macro.label}</span>
|
||||
<span class="cm-detail-macro-val">{fmtNutrient(macro.grams)}g</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Macro detail rows -->
|
||||
<div class="cm-detail-rows">
|
||||
<div class="cm-detail-row">
|
||||
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
|
||||
<span>{fmtNutrient(preview.protein)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row">
|
||||
<span>{isEn ? 'Fat' : 'Fett'}</span>
|
||||
<span>{fmtNutrient(preview.fat)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row sub">
|
||||
<span>{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
|
||||
<span>{fmtNutrient(preview.saturatedFat)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row">
|
||||
<span>{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
|
||||
<span>{fmtNutrient(preview.carbs)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row sub">
|
||||
<span>{isEn ? 'Sugars' : 'Zucker'}</span>
|
||||
<span>{fmtNutrient(preview.sugars)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row">
|
||||
<span>{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
|
||||
<span>{fmtNutrient(preview.fiber)} g</span>
|
||||
</div>
|
||||
</div>
|
||||
<MacroBreakdown
|
||||
calories={preview.calories}
|
||||
protein={preview.protein}
|
||||
fat={preview.fat}
|
||||
carbs={preview.carbs}
|
||||
saturatedFat={preview.saturatedFat}
|
||||
sugars={preview.sugars}
|
||||
fiber={preview.fiber}
|
||||
/>
|
||||
|
||||
<!-- Ingredients list -->
|
||||
<details class="cm-detail-ingredients">
|
||||
@@ -2200,9 +2134,6 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
/* Macro rings (custom meal detail + food detail) */
|
||||
.ring-protein { stroke: var(--nord14); }
|
||||
.ring-fat { stroke: var(--nord12); }
|
||||
.ring-carbs { stroke: var(--nord9); }
|
||||
.ring-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -3467,71 +3398,6 @@
|
||||
color: var(--color-text-tertiary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.cm-detail-cal {
|
||||
text-align: center;
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
}
|
||||
.cm-detail-cal-num {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
.cm-detail-cal-unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
.cm-detail-macros {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
}
|
||||
.cm-detail-macro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
flex: 1;
|
||||
}
|
||||
.cm-detail-macro-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
.cm-detail-macro-val {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.cm-detail-rows {
|
||||
background: var(--color-surface);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.cm-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);
|
||||
}
|
||||
.cm-detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.cm-detail-row.sub span:first-child {
|
||||
padding-left: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.cm-detail-row span:last-child {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cm-detail-ingredients {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { NUTRIENT_META } from '$lib/data/dailyReferenceIntake';
|
||||
import RingGraph from '$lib/components/fitness/RingGraph.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -48,16 +49,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
// --- SVG ring constants (same as NutritionSummary) ---
|
||||
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 - (percent / 100) * ARC_LENGTH;
|
||||
}
|
||||
|
||||
// --- Formatting ---
|
||||
function fmt(v) {
|
||||
if (v == null || isNaN(v)) return '0';
|
||||
@@ -212,30 +203,16 @@
|
||||
<!-- Macro rings -->
|
||||
<div class="macro-rings">
|
||||
{#each [
|
||||
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'ring-protein', grams: scaled(n.protein) },
|
||||
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', cls: 'ring-fat', grams: scaled(n.fat) },
|
||||
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'ring-carbs', grams: scaled(n.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>
|
||||
<span class="macro-grams">{fmt(macro.grams)}g</span>
|
||||
</div>
|
||||
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', color: 'var(--nord14)', grams: scaled(n.protein) },
|
||||
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', color: 'var(--nord12)', grams: scaled(n.fat) },
|
||||
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', color: 'var(--nord9)', grams: scaled(n.carbs) },
|
||||
] as macro (macro.color)}
|
||||
<RingGraph
|
||||
percent={macro.pct}
|
||||
color={macro.color}
|
||||
label={macro.label}
|
||||
sublabel="{fmt(macro.grams)}g"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -464,45 +441,9 @@
|
||||
justify-content: space-around;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
.macro-ring {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
}
|
||||
.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-protein { stroke: var(--nord14); }
|
||||
.ring-fat { stroke: var(--nord12); }
|
||||
.ring-carbs { stroke: var(--nord9); }
|
||||
|
||||
.macro-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
.macro-grams {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-tertiary);
|
||||
.macro-rings :global(.ring-svg) {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
/* Macro detail card */
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
|
||||
import MacroBreakdown from '$lib/components/fitness/MacroBreakdown.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
const s = $derived(fitnessSlugs(lang));
|
||||
@@ -51,14 +52,18 @@
|
||||
|
||||
function ingredientsTotalNutrition(ings) {
|
||||
let calories = 0, protein = 0, fat = 0, carbs = 0;
|
||||
let saturatedFat = 0, sugars = 0, fiber = 0;
|
||||
for (const ing of ings) {
|
||||
const f = ing.amountGrams / 100;
|
||||
calories += (ing.per100g?.calories ?? 0) * f;
|
||||
protein += (ing.per100g?.protein ?? 0) * f;
|
||||
fat += (ing.per100g?.fat ?? 0) * f;
|
||||
carbs += (ing.per100g?.carbs ?? 0) * f;
|
||||
saturatedFat += (ing.per100g?.saturatedFat ?? 0) * f;
|
||||
sugars += (ing.per100g?.sugars ?? 0) * f;
|
||||
fiber += (ing.per100g?.fiber ?? 0) * f;
|
||||
}
|
||||
return { calories, protein, fat, carbs };
|
||||
return { calories, protein, fat, carbs, saturatedFat, sugars, fiber };
|
||||
}
|
||||
|
||||
const formTotals = $derived(ingredientsTotalNutrition(ingredients));
|
||||
@@ -246,14 +251,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Totals -->
|
||||
<!-- Nutrition breakdown -->
|
||||
{#if ingredients.length > 0}
|
||||
<div class="totals-bar">
|
||||
<span class="total-label">{t('total', lang)}</span>
|
||||
<span class="total-macro">{Math.round(formTotals.calories)} {t('kcal', lang)}</span>
|
||||
<span class="total-macro protein">{fmt(formTotals.protein)}g P</span>
|
||||
<span class="total-macro fat">{fmt(formTotals.fat)}g F</span>
|
||||
<span class="total-macro carbs">{fmt(formTotals.carbs)}g C</span>
|
||||
<div class="nutrition-breakdown">
|
||||
<MacroBreakdown
|
||||
calories={formTotals.calories}
|
||||
protein={formTotals.protein}
|
||||
fat={formTotals.fat}
|
||||
carbs={formTotals.carbs}
|
||||
saturatedFat={formTotals.saturatedFat}
|
||||
sugars={formTotals.sugars}
|
||||
fiber={formTotals.fiber}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -621,30 +630,13 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Totals bar ── */
|
||||
.totals-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: color-mix(in srgb, var(--nord8) 8%, transparent);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
/* ── Nutrition breakdown ── */
|
||||
.nutrition-breakdown {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
color: var(--color-text-secondary);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.total-macro {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.total-macro.protein { color: var(--nord14); }
|
||||
.total-macro.fat { color: var(--nord12); }
|
||||
.total-macro.carbs { color: var(--nord9); }
|
||||
|
||||
/* ── Add ingredient button ── */
|
||||
.add-ingredient-btn {
|
||||
display: flex;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import StatsRingGraph from '$lib/components/fitness/StatsRingGraph.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
|
||||
@@ -82,45 +83,6 @@
|
||||
|
||||
const ns = $derived(data.nutritionStats);
|
||||
|
||||
// Macro ring SVG 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;
|
||||
const ARC_ROTATE = 120; // gap centered at bottom
|
||||
|
||||
/** @param {number} percent */
|
||||
function strokeOffset(percent) {
|
||||
return ARC_LENGTH - (percent / 100) * ARC_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SVG coordinates for a triangle marker at a given percentage on the arc.
|
||||
* @param {number} percent
|
||||
*/
|
||||
function targetMarkerPos(percent) {
|
||||
// Arc starts at ARC_ROTATE degrees (120° = 7 o'clock in SVG coords) and sweeps 300° clockwise
|
||||
const startAngle = ARC_ROTATE;
|
||||
const angleDeg = startAngle + (percent / 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: primarily radial (along center→marker line), with tangential
|
||||
// nudge only near 50% where the label would sit right at the top
|
||||
const closeness = 1 - Math.abs(percent - 50) / 50; // 0 at edges, 1 at 50%
|
||||
// Base radial distance: extra +4 for >50% values outside the close-to-50 zone
|
||||
const highBonus = percent > 50 && closeness < 0.4 ? 4 : 0;
|
||||
// Bump for the 30-70% zone (peaks at 40% and 60%)
|
||||
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; // quadratic: stronger nudge near 50%
|
||||
const dir = percent < 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 };
|
||||
}
|
||||
|
||||
const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null));
|
||||
|
||||
const weightChartData = $derived({
|
||||
@@ -352,48 +314,18 @@
|
||||
<div class="macro-header">{t('macro_split', lang)} <span class="macro-subtitle">({t('seven_day_avg', lang)})</span></div>
|
||||
<div class="macro-rings">
|
||||
{#each [
|
||||
{ pct: ns.macroSplit.protein, target: ns.macroTargets?.protein, label: t('protein', lang), cls: 'ring-protein', fill: '#a3be8c' },
|
||||
{ pct: ns.macroSplit.fat, target: ns.macroTargets?.fat, label: t('fat', lang), cls: 'ring-fat', fill: '#d08770' },
|
||||
{ pct: ns.macroSplit.carbs, target: ns.macroTargets?.carbs, label: t('carbs', lang), cls: 'ring-carbs', fill: '#81a1c1' },
|
||||
] as macro (macro.cls)}
|
||||
{ pct: ns.macroSplit.protein, target: ns.macroTargets?.protein, label: t('protein', lang), color: 'var(--nord14)', fill: '#a3be8c' },
|
||||
{ pct: ns.macroSplit.fat, target: ns.macroTargets?.fat, label: t('fat', lang), color: 'var(--nord12)', fill: '#d08770' },
|
||||
{ pct: ns.macroSplit.carbs, target: ns.macroTargets?.carbs, label: t('carbs', lang), color: 'var(--nord9)', fill: '#81a1c1' },
|
||||
] as macro (macro.color)}
|
||||
<div class="macro-ring">
|
||||
<svg class="macro-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 {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)"
|
||||
/>
|
||||
{#if macro.target != null}
|
||||
{@const pos = targetMarkerPos(macro.target)}
|
||||
<path
|
||||
fill={macro.fill}
|
||||
opacity="0.85"
|
||||
stroke={macro.fill}
|
||||
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={macro.fill}
|
||||
x={pos.lx}
|
||||
y={pos.ly}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
>{macro.target}%</text>
|
||||
{/if}
|
||||
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
|
||||
</svg>
|
||||
<span class="macro-label">{macro.label}</span>
|
||||
<StatsRingGraph
|
||||
percent={macro.pct}
|
||||
color={macro.color}
|
||||
label={macro.label}
|
||||
target={macro.target}
|
||||
markerColor={macro.fill}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="macro-legend">
|
||||
@@ -860,49 +792,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
.macro-ring {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
flex: 1;
|
||||
max-width: 130px;
|
||||
}
|
||||
.macro-ring-svg {
|
||||
.macro-ring :global(.ring-svg) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 110px;
|
||||
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-protein { stroke: var(--nord14, #a3be8c); }
|
||||
.ring-fat { stroke: var(--nord12, #d08770); }
|
||||
.ring-carbs { stroke: var(--nord9, #81a1c1); }
|
||||
.macro-label {
|
||||
.macro-ring :global(.ring-label) {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.target-label {
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.macro-legend {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user