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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user