Spaces:
Running
Running
Update src/App.svelte
Browse files- src/App.svelte +63 -44
src/App.svelte
CHANGED
|
@@ -6,23 +6,30 @@
|
|
| 6 |
import { extent, bisector } from 'd3-array';
|
| 7 |
import { line as d3line, area as d3area, curveMonotoneX } from 'd3-shape';
|
| 8 |
|
| 9 |
-
// ---- Data types (now include
|
| 10 |
type Row = {
|
| 11 |
time_min: number;
|
| 12 |
croissant_mgdl_delta: number;
|
| 13 |
croissant_with_anti_spike_mgdl_delta: number;
|
|
|
|
| 14 |
white_rice_mgdl_delta: number;
|
| 15 |
white_rice_with_anti_spike_mgdl_delta: number;
|
|
|
|
| 16 |
};
|
| 17 |
|
| 18 |
let rows: Row[] = [];
|
| 19 |
let loaded = false;
|
| 20 |
|
| 21 |
-
// ----
|
| 22 |
type Food = 'croissant' | 'rice';
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
$: topLabel = food === 'croissant' ? 'Croissant' : 'Rice';
|
| 25 |
-
$:
|
|
|
|
| 26 |
|
| 27 |
// ---- Layout ---------------------------------------------------------
|
| 28 |
const w = 960;
|
|
@@ -79,21 +86,27 @@
|
|
| 79 |
|
| 80 |
// Images
|
| 81 |
const CROISSANT = "/croissant.png";
|
| 82 |
-
const RICE
|
| 83 |
const ANTISPIKE = "/antispike.png";
|
|
|
|
|
|
|
| 84 |
$: topImg = food === 'croissant' ? CROISSANT : RICE;
|
|
|
|
| 85 |
|
| 86 |
onMount(async () => {
|
| 87 |
updateResponsive();
|
| 88 |
window.addEventListener('resize', updateResponsive);
|
| 89 |
|
| 90 |
-
|
|
|
|
| 91 |
rows = csvParse(text, d => ({
|
| 92 |
time_min: +d['time_min']!,
|
| 93 |
croissant_mgdl_delta: +d['croissant_mgdl_delta']!,
|
| 94 |
croissant_with_anti_spike_mgdl_delta: +d['croissant_with_anti_spike_mgdl_delta']!,
|
|
|
|
| 95 |
white_rice_mgdl_delta: +d['white_rice_mgdl_delta']!,
|
| 96 |
-
white_rice_with_anti_spike_mgdl_delta: +d['white_rice_with_anti_spike_mgdl_delta']
|
|
|
|
| 97 |
})) as unknown as Row[];
|
| 98 |
|
| 99 |
loaded = true;
|
|
@@ -101,18 +114,20 @@
|
|
| 101 |
|
| 102 |
onDestroy(() => window.removeEventListener('resize', updateResponsive));
|
| 103 |
|
| 104 |
-
// ---- REACTIVE accessors
|
| 105 |
-
// This is the key fix: these variables CHANGE when `food` changes,
|
| 106 |
-
// so all dependent $: blocks recompute.
|
| 107 |
$: topAcc = food === 'croissant'
|
| 108 |
? ((r: Row) => r.croissant_mgdl_delta)
|
| 109 |
: ((r: Row) => r.white_rice_mgdl_delta);
|
| 110 |
|
| 111 |
$: botAcc = food === 'croissant'
|
| 112 |
-
? (
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
$: if (loaded) {
|
| 117 |
const xDomain = extent(rows, d => d.time_min) as [number, number];
|
| 118 |
const yMin = Math.min(...rows.map(topAcc), ...rows.map(botAcc));
|
|
@@ -124,6 +139,7 @@
|
|
| 124 |
const X = (t: number) => (x ? x(t) : t);
|
| 125 |
const Y = (v: number) => (y ? y(v) : v);
|
| 126 |
|
|
|
|
| 127 |
const makePath = (acc: (r: Row) => number) =>
|
| 128 |
d3line<Row>().x(d => X(d.time_min)).y(d => Y(acc(d))).curve(curveMonotoneX);
|
| 129 |
const makeArea = (acc: (r: Row) => number) =>
|
|
@@ -138,7 +154,7 @@
|
|
| 138 |
areaBot = makeArea(botAcc)(windowed) ?? '';
|
| 139 |
}
|
| 140 |
|
| 141 |
-
// Interpolation for tooltips
|
| 142 |
const rightAt = bisector<Row, number>(d => d.time_min).right;
|
| 143 |
function yAtTime(acc: (r: Row) => number, t: number) {
|
| 144 |
const i = Math.max(0, Math.min(rows.length - 2, rightAt(rows, t) - 1));
|
|
@@ -156,7 +172,7 @@
|
|
| 156 |
}
|
| 157 |
function onLeave() { pointerT = null; }
|
| 158 |
|
| 159 |
-
// Tooltip values
|
| 160 |
let markerX: number | null = null;
|
| 161 |
let topVal: number | null = null, botVal: number | null = null;
|
| 162 |
let topY: number | null = null, botY: number | null = null;
|
|
@@ -192,13 +208,10 @@
|
|
| 192 |
$: topMsgText = topVal !== null ? msgTop(topVal) : "";
|
| 193 |
$: botMsgText = botVal !== null ? msgBot(botVal) : "";
|
| 194 |
|
| 195 |
-
//
|
| 196 |
-
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
|
| 197 |
-
// Fixed pixel anchor so the title doesn't move when the scale changes
|
| 198 |
$: yTitleX = xTickCol - 6;
|
| 199 |
$: yTitleY = plotY + (isMobile ? 20 : 30);
|
| 200 |
$: yTitleDy = yTitleFS + (isMobile ? 28 : 4);
|
| 201 |
-
|
| 202 |
</script>
|
| 203 |
|
| 204 |
<div class="container">
|
|
@@ -211,7 +224,7 @@
|
|
| 211 |
<br> <i>Hint: it's interactive so feel free to hover over. </i>
|
| 212 |
</div>
|
| 213 |
|
| 214 |
-
<!--
|
| 215 |
<div class="switcher imgs" role="tablist" aria-label="Choose food">
|
| 216 |
<button class="imgbtn" class:selected={food==='croissant'}
|
| 217 |
aria-selected={food==='croissant'}
|
|
@@ -227,20 +240,22 @@
|
|
| 227 |
</button>
|
| 228 |
</div>
|
| 229 |
|
| 230 |
-
<!-- =================== TOP:
|
| 231 |
-
<div class="card" style="position:relative; margin-bottom:
|
| 232 |
{#if loaded}
|
| 233 |
<svg viewBox={`0 0 ${w} ${H}`} style="width:100%;height:auto;touch-action:none;display:block"
|
| 234 |
on:pointermove={onPtr} on:pointerdown={onPtr} on:pointerleave={onLeave}>
|
| 235 |
|
|
|
|
| 236 |
<rect x={plotX} y={plotY} width={plotW} height={Y(30) - plotY} fill="rgba(223,64,133,0.12)" />
|
| 237 |
<rect x={plotX} y={Y(30)} width={plotW} height={plotBottom - Y(30)} fill="rgba(40,200,120,0.14)" />
|
| 238 |
|
|
|
|
| 239 |
<text x={yTitleX} y={yTitleY} fill="#4a4f93" font-size={yTitleFS} text-anchor="end" dominant-baseline="text-before-edge">
|
| 240 |
glucose <tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
| 241 |
</text>
|
| 242 |
|
| 243 |
-
<!-- Callout
|
| 244 |
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>{topLabel}</text>
|
| 245 |
<image href={topImg} x={m.left + leftInset} y={plotY + calloutTop + labelFS + 6} height={imgH} preserveAspectRatio="xMinYMin meet" />
|
| 246 |
|
|
@@ -248,6 +263,7 @@
|
|
| 248 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 249 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
| 250 |
|
|
|
|
| 251 |
{#each [{t:0,label:"eating time"},{t:120,label:"2 hours later"}] as xl}
|
| 252 |
<text x={X(xl.t)} y={(H - m.bottom) + Math.min(m.bottom - 6, Math.round(axisXFS * 0.9))}
|
| 253 |
text-anchor="middle" fill="#4a4f93" font-size={axisXFS}>{xl.label}</text>
|
|
@@ -256,19 +272,14 @@
|
|
| 256 |
<line x1={X(t)} x2={X(t)} y1={plotBottom - xTickLen} y2={plotBottom} stroke="#4a4f93" stroke-width="2" />
|
| 257 |
{/each}
|
| 258 |
|
| 259 |
-
<!-- Y ticks
|
| 260 |
<text x={xTickCol} y={Y(60) + yOff60} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+60</text>
|
| 261 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 262 |
<text x={xTickCol} y={Y(0) + yOff0 } fill="#4a4f93" font-size={axisYFS} text-anchor="end">baseline</text>
|
| 263 |
|
| 264 |
-
<!-- Clip
|
| 265 |
-
<defs>
|
| 266 |
-
|
| 267 |
-
<rect x={plotX} y={plotY} width={plotW} height={Y(0) - plotY} />
|
| 268 |
-
</clipPath>
|
| 269 |
-
</defs>
|
| 270 |
-
|
| 271 |
-
<path d={areaTop} fill="#111" clip-path="url(#clipTopAboveBaseline)"/>
|
| 272 |
<path d={pathTop} fill="none" stroke="#111" stroke-width="3.5"/>
|
| 273 |
|
| 274 |
{#if markerX !== null && topY !== null}
|
|
@@ -288,8 +299,14 @@
|
|
| 288 |
{:else} Loading… {/if}
|
| 289 |
</div>
|
| 290 |
|
| 291 |
-
<!--
|
| 292 |
-
<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
{#if loaded}
|
| 294 |
<svg viewBox={`0 0 ${w} ${H}`} style="width:100%;height:auto;touch-action:none;display:block"
|
| 295 |
on:pointermove={onPtr} on:pointerdown={onPtr} on:pointerleave={onLeave}>
|
|
@@ -301,8 +318,9 @@
|
|
| 301 |
glucose <tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
| 302 |
</text>
|
| 303 |
|
| 304 |
-
|
| 305 |
-
<
|
|
|
|
| 306 |
|
| 307 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 308 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
|
@@ -319,13 +337,8 @@
|
|
| 319 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 320 |
<text x={xTickCol} y={Y(0) + yOff0 } fill="#4a4f93" font-size={axisYFS} text-anchor="end">baseline</text>
|
| 321 |
|
| 322 |
-
<defs>
|
| 323 |
-
|
| 324 |
-
<rect x={plotX} y={plotY} width={plotW} height={Y(0) - plotY} />
|
| 325 |
-
</clipPath>
|
| 326 |
-
</defs>
|
| 327 |
-
|
| 328 |
-
<path d={areaBot} fill="#111" clip-path="url(#clipBotAboveBaseline)"/>
|
| 329 |
<path d={pathBot} fill="none" stroke="#111" stroke-width="3.5"/>
|
| 330 |
|
| 331 |
{#if markerX !== null && botY !== null}
|
|
@@ -344,4 +357,10 @@
|
|
| 344 |
{/if}
|
| 345 |
{:else} Loading… {/if}
|
| 346 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
</div>
|
|
|
|
| 6 |
import { extent, bisector } from 'd3-array';
|
| 7 |
import { line as d3line, area as d3area, curveMonotoneX } from 'd3-shape';
|
| 8 |
|
| 9 |
+
// ---- Data types (now include Veggie Starter columns) ----------------
|
| 10 |
type Row = {
|
| 11 |
time_min: number;
|
| 12 |
croissant_mgdl_delta: number;
|
| 13 |
croissant_with_anti_spike_mgdl_delta: number;
|
| 14 |
+
croissant_with_veggie_starter_mgdl_delta: number;
|
| 15 |
white_rice_mgdl_delta: number;
|
| 16 |
white_rice_with_anti_spike_mgdl_delta: number;
|
| 17 |
+
white_rice_with_veggie_starter_mgdl_delta: number;
|
| 18 |
};
|
| 19 |
|
| 20 |
let rows: Row[] = [];
|
| 21 |
let loaded = false;
|
| 22 |
|
| 23 |
+
// ---- Toggles --------------------------------------------------------
|
| 24 |
type Food = 'croissant' | 'rice';
|
| 25 |
+
type Mod = 'anti' | 'veggie';
|
| 26 |
+
|
| 27 |
+
let food: Food = 'croissant'; // default food
|
| 28 |
+
let mod: Mod = 'anti'; // default modifier
|
| 29 |
+
|
| 30 |
$: topLabel = food === 'croissant' ? 'Croissant' : 'Rice';
|
| 31 |
+
$: bottomHead = mod === 'anti' ? 'With Anti-Spike' : 'With Veggie Starter';
|
| 32 |
+
$: bottomLabel = `${topLabel} + ${mod === 'anti' ? 'Anti-Spike' : 'Veggie Starter'}`;
|
| 33 |
|
| 34 |
// ---- Layout ---------------------------------------------------------
|
| 35 |
const w = 960;
|
|
|
|
| 86 |
|
| 87 |
// Images
|
| 88 |
const CROISSANT = "/croissant.png";
|
| 89 |
+
const RICE = "/rice.png";
|
| 90 |
const ANTISPIKE = "/antispike.png";
|
| 91 |
+
const BROCCOLI = "/broccoli.png";
|
| 92 |
+
|
| 93 |
$: topImg = food === 'croissant' ? CROISSANT : RICE;
|
| 94 |
+
$: bottomImg = mod === 'anti' ? ANTISPIKE : BROCCOLI;
|
| 95 |
|
| 96 |
onMount(async () => {
|
| 97 |
updateResponsive();
|
| 98 |
window.addEventListener('resize', updateResponsive);
|
| 99 |
|
| 100 |
+
// Use your new CSV with Veggie Starter columns
|
| 101 |
+
const text = await (await fetch('/glucosedata3.csv')).text();
|
| 102 |
rows = csvParse(text, d => ({
|
| 103 |
time_min: +d['time_min']!,
|
| 104 |
croissant_mgdl_delta: +d['croissant_mgdl_delta']!,
|
| 105 |
croissant_with_anti_spike_mgdl_delta: +d['croissant_with_anti_spike_mgdl_delta']!,
|
| 106 |
+
croissant_with_veggie_starter_mgdl_delta: +d['croissant_with_veggie_starter_mgdl_delta']!,
|
| 107 |
white_rice_mgdl_delta: +d['white_rice_mgdl_delta']!,
|
| 108 |
+
white_rice_with_anti_spike_mgdl_delta: +d['white_rice_with_anti_spike_mgdl_delta']!,
|
| 109 |
+
white_rice_with_veggie_starter_mgdl_delta: +d['white_rice_with_veggie_starter_mgdl_delta']!
|
| 110 |
})) as unknown as Row[];
|
| 111 |
|
| 112 |
loaded = true;
|
|
|
|
| 114 |
|
| 115 |
onDestroy(() => window.removeEventListener('resize', updateResponsive));
|
| 116 |
|
| 117 |
+
// ---- REACTIVE accessors --------------------------------------------
|
|
|
|
|
|
|
| 118 |
$: topAcc = food === 'croissant'
|
| 119 |
? ((r: Row) => r.croissant_mgdl_delta)
|
| 120 |
: ((r: Row) => r.white_rice_mgdl_delta);
|
| 121 |
|
| 122 |
$: botAcc = food === 'croissant'
|
| 123 |
+
? (mod === 'anti'
|
| 124 |
+
? ((r: Row) => r.croissant_with_anti_spike_mgdl_delta)
|
| 125 |
+
: ((r: Row) => r.croissant_with_veggie_starter_mgdl_delta))
|
| 126 |
+
: (mod === 'anti'
|
| 127 |
+
? ((r: Row) => r.white_rice_with_anti_spike_mgdl_delta)
|
| 128 |
+
: ((r: Row) => r.white_rice_with_veggie_starter_mgdl_delta));
|
| 129 |
+
|
| 130 |
+
// ---- Scales (auto-fit to the two displayed series) -----------------
|
| 131 |
$: if (loaded) {
|
| 132 |
const xDomain = extent(rows, d => d.time_min) as [number, number];
|
| 133 |
const yMin = Math.min(...rows.map(topAcc), ...rows.map(botAcc));
|
|
|
|
| 139 |
const X = (t: number) => (x ? x(t) : t);
|
| 140 |
const Y = (v: number) => (y ? y(v) : v);
|
| 141 |
|
| 142 |
+
// Paths + filled area (only 0..100 and only above baseline)
|
| 143 |
const makePath = (acc: (r: Row) => number) =>
|
| 144 |
d3line<Row>().x(d => X(d.time_min)).y(d => Y(acc(d))).curve(curveMonotoneX);
|
| 145 |
const makeArea = (acc: (r: Row) => number) =>
|
|
|
|
| 154 |
areaBot = makeArea(botAcc)(windowed) ?? '';
|
| 155 |
}
|
| 156 |
|
| 157 |
+
// Interpolation for tooltips
|
| 158 |
const rightAt = bisector<Row, number>(d => d.time_min).right;
|
| 159 |
function yAtTime(acc: (r: Row) => number, t: number) {
|
| 160 |
const i = Math.max(0, Math.min(rows.length - 2, rightAt(rows, t) - 1));
|
|
|
|
| 172 |
}
|
| 173 |
function onLeave() { pointerT = null; }
|
| 174 |
|
| 175 |
+
// Tooltip values
|
| 176 |
let markerX: number | null = null;
|
| 177 |
let topVal: number | null = null, botVal: number | null = null;
|
| 178 |
let topY: number | null = null, botY: number | null = null;
|
|
|
|
| 208 |
$: topMsgText = topVal !== null ? msgTop(topVal) : "";
|
| 209 |
$: botMsgText = botVal !== null ? msgBot(botVal) : "";
|
| 210 |
|
| 211 |
+
// Fixed pixel anchor so the axis title doesn't move when scale changes
|
|
|
|
|
|
|
| 212 |
$: yTitleX = xTickCol - 6;
|
| 213 |
$: yTitleY = plotY + (isMobile ? 20 : 30);
|
| 214 |
$: yTitleDy = yTitleFS + (isMobile ? 28 : 4);
|
|
|
|
| 215 |
</script>
|
| 216 |
|
| 217 |
<div class="container">
|
|
|
|
| 224 |
<br> <i>Hint: it's interactive so feel free to hover over. </i>
|
| 225 |
</div>
|
| 226 |
|
| 227 |
+
<!-- Food (image) toggle -->
|
| 228 |
<div class="switcher imgs" role="tablist" aria-label="Choose food">
|
| 229 |
<button class="imgbtn" class:selected={food==='croissant'}
|
| 230 |
aria-selected={food==='croissant'}
|
|
|
|
| 240 |
</button>
|
| 241 |
</div>
|
| 242 |
|
| 243 |
+
<!-- =================== TOP: Food alone =================== -->
|
| 244 |
+
<div class="card" style="position:relative; margin-bottom:6px;">
|
| 245 |
{#if loaded}
|
| 246 |
<svg viewBox={`0 0 ${w} ${H}`} style="width:100%;height:auto;touch-action:none;display:block"
|
| 247 |
on:pointermove={onPtr} on:pointerdown={onPtr} on:pointerleave={onLeave}>
|
| 248 |
|
| 249 |
+
<!-- Bands -->
|
| 250 |
<rect x={plotX} y={plotY} width={plotW} height={Y(30) - plotY} fill="rgba(223,64,133,0.12)" />
|
| 251 |
<rect x={plotX} y={Y(30)} width={plotW} height={plotBottom - Y(30)} fill="rgba(40,200,120,0.14)" />
|
| 252 |
|
| 253 |
+
<!-- y-axis title -->
|
| 254 |
<text x={yTitleX} y={yTitleY} fill="#4a4f93" font-size={yTitleFS} text-anchor="end" dominant-baseline="text-before-edge">
|
| 255 |
glucose <tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
| 256 |
</text>
|
| 257 |
|
| 258 |
+
<!-- Callout -->
|
| 259 |
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>{topLabel}</text>
|
| 260 |
<image href={topImg} x={m.left + leftInset} y={plotY + calloutTop + labelFS + 6} height={imgH} preserveAspectRatio="xMinYMin meet" />
|
| 261 |
|
|
|
|
| 263 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 264 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
| 265 |
|
| 266 |
+
<!-- X labels + ticks -->
|
| 267 |
{#each [{t:0,label:"eating time"},{t:120,label:"2 hours later"}] as xl}
|
| 268 |
<text x={X(xl.t)} y={(H - m.bottom) + Math.min(m.bottom - 6, Math.round(axisXFS * 0.9))}
|
| 269 |
text-anchor="middle" fill="#4a4f93" font-size={axisXFS}>{xl.label}</text>
|
|
|
|
| 272 |
<line x1={X(t)} x2={X(t)} y1={plotBottom - xTickLen} y2={plotBottom} stroke="#4a4f93" stroke-width="2" />
|
| 273 |
{/each}
|
| 274 |
|
| 275 |
+
<!-- Y ticks -->
|
| 276 |
<text x={xTickCol} y={Y(60) + yOff60} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+60</text>
|
| 277 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 278 |
<text x={xTickCol} y={Y(0) + yOff0 } fill="#4a4f93" font-size={axisYFS} text-anchor="end">baseline</text>
|
| 279 |
|
| 280 |
+
<!-- Clip + path -->
|
| 281 |
+
<defs><clipPath id="clipTop"><rect x={plotX} y={plotY} width={plotW} height={Y(0) - plotY} /></clipPath></defs>
|
| 282 |
+
<path d={areaTop} fill="#111" clip-path="url(#clipTop)"/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
<path d={pathTop} fill="none" stroke="#111" stroke-width="3.5"/>
|
| 284 |
|
| 285 |
{#if markerX !== null && topY !== null}
|
|
|
|
| 299 |
{:else} Loading… {/if}
|
| 300 |
</div>
|
| 301 |
|
| 302 |
+
<!-- Modifier buttons (affect bottom plot) -->
|
| 303 |
+
<div class="switcher" role="tablist" aria-label="Choose modifier for comparison below" style="margin-bottom:14px;">
|
| 304 |
+
<button class:selected={mod==='anti'} aria-selected={mod==='anti'} on:click={() => mod='anti'}>Anti-Spike</button>
|
| 305 |
+
<button class:selected={mod==='veggie'} aria-selected={mod==='veggie'} on:click={() => mod='veggie'}>Veggie Starter</button>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
<!-- =================== BOTTOM: Food + modifier =================== -->
|
| 309 |
+
<div class="card" style="position:relative; margin-bottom:6px;">
|
| 310 |
{#if loaded}
|
| 311 |
<svg viewBox={`0 0 ${w} ${H}`} style="width:100%;height:auto;touch-action:none;display:block"
|
| 312 |
on:pointermove={onPtr} on:pointerdown={onPtr} on:pointerleave={onLeave}>
|
|
|
|
| 318 |
glucose <tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
| 319 |
</text>
|
| 320 |
|
| 321 |
+
<!-- Reactive callout for modifier -->
|
| 322 |
+
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>{bottomHead}</text>
|
| 323 |
+
<image href={bottomImg} x={m.left + leftInset} y={plotY + calloutTop + labelFS + 6} height={imgH} preserveAspectRatio="xMinYMin meet" />
|
| 324 |
|
| 325 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 326 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
|
|
|
| 337 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 338 |
<text x={xTickCol} y={Y(0) + yOff0 } fill="#4a4f93" font-size={axisYFS} text-anchor="end">baseline</text>
|
| 339 |
|
| 340 |
+
<defs><clipPath id="clipBot"><rect x={plotX} y={plotY} width={plotW} height={Y(0) - plotY} /></clipPath></defs>
|
| 341 |
+
<path d={areaBot} fill="#111" clip-path="url(#clipBot)"/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
<path d={pathBot} fill="none" stroke="#111" stroke-width="3.5"/>
|
| 343 |
|
| 344 |
{#if markerX !== null && botY !== null}
|
|
|
|
| 357 |
{/if}
|
| 358 |
{:else} Loading… {/if}
|
| 359 |
</div>
|
| 360 |
+
|
| 361 |
+
<!-- Duplicate modifier buttons below the bottom plot (as requested) -->
|
| 362 |
+
<div class="switcher" role="tablist" aria-label="Choose modifier (bottom)">
|
| 363 |
+
<button class:selected={mod==='anti'} aria-selected={mod==='anti'} on:click={() => mod='anti'}>Anti-Spike</button>
|
| 364 |
+
<button class:selected={mod==='veggie'} aria-selected={mod==='veggie'} on:click={() => mod='veggie'}>Veggie Starter</button>
|
| 365 |
+
</div>
|
| 366 |
</div>
|