Spaces:
Running
Running
Update src/App.svelte
Browse files- src/App.svelte +67 -86
src/App.svelte
CHANGED
|
@@ -10,96 +10,73 @@
|
|
| 10 |
time_min: number;
|
| 11 |
croissant_mgdl_delta: number;
|
| 12 |
croissant_with_anti_spike_mgdl_delta: number;
|
|
|
|
|
|
|
| 13 |
};
|
| 14 |
|
| 15 |
let rows: Row[] = [];
|
| 16 |
let loaded = false;
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
/** -------- Layout --------------------------------------------------- **/
|
| 19 |
const w = 960;
|
| 20 |
let H = 432; // desktop height
|
|
|
|
| 21 |
|
| 22 |
-
// ➜ Add more left white space so y-labels never clip
|
| 23 |
-
let m = { top: 20, right: 11, bottom: 28, left: 92 }; // desktop LEFT was ~54
|
| 24 |
-
|
| 25 |
-
// Derived plot rect
|
| 26 |
$: plotX = m.left;
|
| 27 |
$: plotY = m.top;
|
| 28 |
$: plotW = w - m.left - m.right;
|
| 29 |
$: plotBottom = H - m.bottom;
|
| 30 |
|
| 31 |
-
// Callouts (down a touch)
|
| 32 |
const leftInset = 54;
|
| 33 |
let labelFS = 18;
|
| 34 |
let imgH = 100;
|
|
|
|
| 35 |
$: calloutTop = isMobile ? 26 : 24;
|
| 36 |
|
| 37 |
-
// Axes font sizes
|
| 38 |
let axisXFS = 16;
|
| 39 |
let axisYFS = 14;
|
| 40 |
-
|
| 41 |
-
// y-axis title “glucose (mg/dL)”
|
| 42 |
let yTitleFS = 12;
|
| 43 |
|
| 44 |
-
// Tooltip position
|
| 45 |
let tooltipW = 200;
|
| 46 |
let tooltipTop = 28;
|
| 47 |
let tooltipRight = 120;
|
| 48 |
|
| 49 |
-
// X ticks (inside the band, centered under labels)
|
| 50 |
const xTicksVals = [0, 120];
|
| 51 |
$: xTickLen = 10;
|
| 52 |
-
|
| 53 |
-
let isMobile = false;
|
| 54 |
-
|
| 55 |
-
// ➜ Column where y-tick TEXT sits, to the LEFT of the plot (inside white area)
|
| 56 |
-
// We keep a comfortable gap so long labels don’t clip.
|
| 57 |
$: xTickCol = m.left - 12;
|
| 58 |
-
|
| 59 |
-
// Fine vertical nudges for tick text
|
| 60 |
$: yOff60 = 30;
|
| 61 |
$: yOff30 = 6;
|
| 62 |
$: yOff0 = -8;
|
| 63 |
|
| 64 |
function updateResponsive() {
|
| 65 |
isMobile = window.matchMedia('(max-width: 640px)').matches;
|
| 66 |
-
|
| 67 |
if (isMobile) {
|
| 68 |
H = Math.round(432 * 1.5);
|
| 69 |
-
// ➜ Add extra left space on mobile too so “baseline” never clips
|
| 70 |
m = { top: 22, right: 11, bottom: 56, left: 125 };
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
imgH = 130;
|
| 74 |
-
axisXFS = 32;
|
| 75 |
-
axisYFS = 28;
|
| 76 |
-
yTitleFS = 28;
|
| 77 |
-
tooltipW = 130;
|
| 78 |
-
tooltipTop = 24;
|
| 79 |
-
tooltipRight = 12;
|
| 80 |
} else {
|
| 81 |
H = 432;
|
| 82 |
m = { top: 20, right: 11, bottom: 28, left: 85 };
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
imgH = 100;
|
| 86 |
-
axisXFS = 16;
|
| 87 |
-
axisYFS = 14;
|
| 88 |
-
yTitleFS = 12;
|
| 89 |
-
tooltipW = 200;
|
| 90 |
-
tooltipTop = 28;
|
| 91 |
-
tooltipRight = 120;
|
| 92 |
}
|
| 93 |
}
|
| 94 |
|
| 95 |
let x: ((n: number) => number) | null = null;
|
| 96 |
let y: ((n: number) => number) | null = null;
|
| 97 |
-
|
| 98 |
-
// Pointer state
|
| 99 |
let pointerT: number | null = null;
|
| 100 |
|
| 101 |
const CROISSANT = "/croissant.png";
|
|
|
|
| 102 |
const ANTISPIKE = "/antispike.png";
|
|
|
|
| 103 |
|
| 104 |
onMount(async () => {
|
| 105 |
updateResponsive();
|
|
@@ -109,7 +86,9 @@
|
|
| 109 |
rows = csvParse(text, d => ({
|
| 110 |
time_min: +d['time_min']!,
|
| 111 |
croissant_mgdl_delta: +d['croissant_mgdl_delta']!,
|
| 112 |
-
croissant_with_anti_spike_mgdl_delta: +d['croissant_with_anti_spike_mgdl_delta']
|
|
|
|
|
|
|
| 113 |
})) as unknown as Row[];
|
| 114 |
|
| 115 |
loaded = true;
|
|
@@ -117,11 +96,17 @@
|
|
| 117 |
|
| 118 |
onDestroy(() => window.removeEventListener('resize', updateResponsive));
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
/** -------- Scales --------------------------------------------------- **/
|
| 121 |
$: if (loaded) {
|
| 122 |
const xDomain = extent(rows, d => d.time_min) as [number, number];
|
| 123 |
-
const yMin = Math.min(...rows.map(
|
| 124 |
-
const yMax = Math.max(...rows.map(
|
| 125 |
x = scaleLinear().domain(xDomain).range([m.left, m.left + (w - m.left - m.right)]);
|
| 126 |
y = scaleLinear().domain([yMin - 5, yMax + 5]).range([H - m.bottom, m.top]);
|
| 127 |
}
|
|
@@ -129,7 +114,6 @@
|
|
| 129 |
const X = (t: number) => (x ? x(t) : t);
|
| 130 |
const Y = (v: number) => (y ? y(v) : v);
|
| 131 |
|
| 132 |
-
// Paths + filled area (only 0..100 and only above baseline)
|
| 133 |
const makePath = (acc: (r: Row) => number) =>
|
| 134 |
d3line<Row>().x(d => X(d.time_min)).y(d => Y(acc(d))).curve(curveMonotoneX);
|
| 135 |
const makeArea = (acc: (r: Row) => number) =>
|
|
@@ -138,13 +122,12 @@
|
|
| 138 |
let pathTop = '', pathBot = '', areaTop = '', areaBot = '';
|
| 139 |
$: if (loaded) {
|
| 140 |
const windowed = rows.filter(d => d.time_min >= 0 && d.time_min <= 100);
|
| 141 |
-
pathTop = makePath(
|
| 142 |
-
pathBot = makePath(
|
| 143 |
-
areaTop = makeArea(
|
| 144 |
-
areaBot = makeArea(
|
| 145 |
}
|
| 146 |
|
| 147 |
-
// Interpolation
|
| 148 |
const rightAt = bisector<Row, number>(d => d.time_min).right;
|
| 149 |
function yAtTime(acc: (r: Row) => number, t: number) {
|
| 150 |
const i = Math.max(0, Math.min(rows.length - 2, rightAt(rows, t) - 1));
|
|
@@ -169,8 +152,8 @@
|
|
| 169 |
|
| 170 |
$: if (loaded && pointerT !== null && y) {
|
| 171 |
markerX = X(pointerT);
|
| 172 |
-
topVal = yAtTime(
|
| 173 |
-
botVal = yAtTime(
|
| 174 |
topY = Y(topVal);
|
| 175 |
botY = Y(botVal);
|
| 176 |
} else {
|
|
@@ -189,7 +172,7 @@
|
|
| 189 |
: "At baseline";
|
| 190 |
|
| 191 |
const msgBot = (v: number) =>
|
| 192 |
-
v >= 22 ? "Reduced spike vs
|
| 193 |
: v >= 12 ? "Smaller spike → steadier energy"
|
| 194 |
: v <= 2 && v >= -2 ? "Near baseline ✅"
|
| 195 |
: v < -1 ? "Gentle dip, then recovery"
|
|
@@ -197,17 +180,9 @@
|
|
| 197 |
|
| 198 |
$: topMsgText = topVal !== null ? msgTop(topVal) : "";
|
| 199 |
$: botMsgText = botVal !== null ? msgBot(botVal) : "";
|
| 200 |
-
const avenir = `'Avenir Next', Avenir, 'Helvetica Neue', Arial, sans-serif`;
|
| 201 |
-
const serif = `var(--font-serif)`;
|
| 202 |
-
$: topMsgFont = topMsgText === "At baseline" ? avenir : serif;
|
| 203 |
-
$: botMsgFont = botMsgText === "At baseline" ? avenir : serif;
|
| 204 |
-
|
| 205 |
-
// X-label Y in white margin (tight)
|
| 206 |
-
$: xLabelY = (H - m.bottom) + Math.min(m.bottom - 6, Math.round(axisXFS * 0.9));
|
| 207 |
|
| 208 |
-
// y-axis title ABOVE +60 and to the LEFT of the +60 label
|
| 209 |
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
|
| 210 |
-
$: yTitleX = xTickCol - 6;
|
| 211 |
$: yTitleY = (loaded && y)
|
| 212 |
? (Y(60) - (yTitleFS * 2 + (isMobile ? -110 : -70)))
|
| 213 |
: plotY + 6;
|
|
@@ -224,44 +199,52 @@
|
|
| 224 |
<br> <i>Hint: it's interactive so feel free to hover over. </i>
|
| 225 |
</div>
|
| 226 |
|
| 227 |
-
<!--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
<div class="card" style="position:relative; margin-bottom:14px;">
|
| 229 |
{#if loaded}
|
| 230 |
<svg viewBox={`0 0 ${w} ${H}`} style="width:100%;height:auto;touch-action:none;display:block"
|
| 231 |
on:pointermove={onPtr} on:pointerdown={onPtr} on:pointerleave={onLeave}>
|
| 232 |
|
| 233 |
-
<!-- Bands inside plot box -->
|
| 234 |
<rect x={plotX} y={plotY} width={plotW} height={Y(30) - plotY} fill="rgba(223,64,133,0.12)" />
|
| 235 |
<rect x={plotX} y={Y(30)} width={plotW} height={plotBottom - Y(30)} fill="rgba(40,200,120,0.14)" />
|
| 236 |
|
| 237 |
-
<!-- y-axis title ABOVE +60 and LEFT of tick labels -->
|
| 238 |
<text x={yTitleX} y={yTitleY} fill="#4a4f93" font-size={yTitleFS} text-anchor="end" dominant-baseline="text-before-edge">
|
| 239 |
-
glucose
|
| 240 |
-
<tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
| 241 |
</text>
|
| 242 |
|
| 243 |
-
<!--
|
| 244 |
-
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>
|
| 245 |
-
<image href={
|
| 246 |
|
| 247 |
-
<!-- Guides -->
|
| 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 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
{/each}
|
| 255 |
{#each xTicksVals as t}
|
| 256 |
<line x1={X(t)} x2={X(t)} y1={plotBottom - xTickLen} y2={plotBottom} stroke="#4a4f93" stroke-width="2" />
|
| 257 |
{/each}
|
| 258 |
|
| 259 |
-
<!-- Y ticks in LEFT WHITE MARGIN -->
|
| 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 }
|
| 263 |
|
| 264 |
-
<!-- Clip above baseline -->
|
| 265 |
<defs>
|
| 266 |
<clipPath id="clipTopAboveBaseline">
|
| 267 |
<rect x={plotX} y={plotY} width={plotW} height={Y(0) - plotY} />
|
|
@@ -279,11 +262,11 @@
|
|
| 279 |
|
| 280 |
{#if topVal !== null}
|
| 281 |
<div class="tooltip" style={`top:${tooltipTop}px; right:${tooltipRight}px; left:auto; width:${tooltipW}px;`}>
|
| 282 |
-
<div style=
|
| 283 |
{topMsgText}
|
| 284 |
</div>
|
| 285 |
<div style="display:flex;justify-content:space-between;gap:12px;">
|
| 286 |
-
<div>
|
| 287 |
<strong>{topVal.toFixed(0)} mg/dL</strong>
|
| 288 |
</div>
|
| 289 |
</div>
|
|
@@ -300,10 +283,8 @@
|
|
| 300 |
<rect x={plotX} y={plotY} width={plotW} height={Y(30) - plotY} fill="rgba(223,64,133,0.12)" />
|
| 301 |
<rect x={plotX} y={Y(30)} width={plotW} height={plotBottom - Y(30)} fill="rgba(40,200,120,0.14)" />
|
| 302 |
|
| 303 |
-
<!-- y-axis title ABOVE +60 and LEFT of tick labels -->
|
| 304 |
<text x={yTitleX} y={yTitleY} fill="#4a4f93" font-size={yTitleFS} text-anchor="end" dominant-baseline="text-before-edge">
|
| 305 |
-
glucose
|
| 306 |
-
<tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
| 307 |
</text>
|
| 308 |
|
| 309 |
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>With Anti-Spike</text>
|
|
@@ -312,17 +293,17 @@
|
|
| 312 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 313 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
| 314 |
|
| 315 |
-
{#each
|
| 316 |
-
<text x={X(xl.t)} y={
|
|
|
|
| 317 |
{/each}
|
| 318 |
{#each xTicksVals as t}
|
| 319 |
<line x1={X(t)} x2={X(t)} y1={plotBottom - xTickLen} y2={plotBottom} stroke="#4a4f93" stroke-width="2" />
|
| 320 |
{/each}
|
| 321 |
|
| 322 |
-
<!-- Y ticks in LEFT WHITE MARGIN -->
|
| 323 |
<text x={xTickCol} y={Y(60) + yOff60} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+60</text>
|
| 324 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 325 |
-
<text x={xTickCol} y={Y(0) + yOff0 }
|
| 326 |
|
| 327 |
<defs>
|
| 328 |
<clipPath id="clipBotAboveBaseline">
|
|
@@ -341,11 +322,11 @@
|
|
| 341 |
|
| 342 |
{#if botVal !== null}
|
| 343 |
<div class="tooltip" style={`top:${tooltipTop}px; right:${tooltipRight}px; left:auto; width:${tooltipW}px;`}>
|
| 344 |
-
<div style=
|
| 345 |
{botMsgText}
|
| 346 |
</div>
|
| 347 |
<div style="display:flex;justify-content:space-between;gap:12px;">
|
| 348 |
-
<div>
|
| 349 |
<strong>{botVal.toFixed(0)} mg/dL</strong>
|
| 350 |
</div>
|
| 351 |
</div>
|
|
|
|
| 10 |
time_min: number;
|
| 11 |
croissant_mgdl_delta: number;
|
| 12 |
croissant_with_anti_spike_mgdl_delta: number;
|
| 13 |
+
white_rice_mgdl_delta: number;
|
| 14 |
+
white_rice_with_anti_spike_mgdl_delta: number;
|
| 15 |
};
|
| 16 |
|
| 17 |
let rows: Row[] = [];
|
| 18 |
let loaded = false;
|
| 19 |
|
| 20 |
+
/** -------- Food toggle -------------------------------------------- **/
|
| 21 |
+
type Food = 'croissant' | 'rice';
|
| 22 |
+
let food: Food = 'croissant'; // default selection
|
| 23 |
+
$: topLabel = food === 'croissant' ? 'Croissant' : 'Rice';
|
| 24 |
+
$: bottomLabel = `${topLabel} + Anti-Spike`;
|
| 25 |
+
|
| 26 |
/** -------- Layout --------------------------------------------------- **/
|
| 27 |
const w = 960;
|
| 28 |
let H = 432; // desktop height
|
| 29 |
+
let m = { top: 20, right: 11, bottom: 28, left: 92 };
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
$: plotX = m.left;
|
| 32 |
$: plotY = m.top;
|
| 33 |
$: plotW = w - m.left - m.right;
|
| 34 |
$: plotBottom = H - m.bottom;
|
| 35 |
|
|
|
|
| 36 |
const leftInset = 54;
|
| 37 |
let labelFS = 18;
|
| 38 |
let imgH = 100;
|
| 39 |
+
let isMobile = false;
|
| 40 |
$: calloutTop = isMobile ? 26 : 24;
|
| 41 |
|
|
|
|
| 42 |
let axisXFS = 16;
|
| 43 |
let axisYFS = 14;
|
|
|
|
|
|
|
| 44 |
let yTitleFS = 12;
|
| 45 |
|
|
|
|
| 46 |
let tooltipW = 200;
|
| 47 |
let tooltipTop = 28;
|
| 48 |
let tooltipRight = 120;
|
| 49 |
|
|
|
|
| 50 |
const xTicksVals = [0, 120];
|
| 51 |
$: xTickLen = 10;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
$: xTickCol = m.left - 12;
|
|
|
|
|
|
|
| 53 |
$: yOff60 = 30;
|
| 54 |
$: yOff30 = 6;
|
| 55 |
$: yOff0 = -8;
|
| 56 |
|
| 57 |
function updateResponsive() {
|
| 58 |
isMobile = window.matchMedia('(max-width: 640px)').matches;
|
|
|
|
| 59 |
if (isMobile) {
|
| 60 |
H = Math.round(432 * 1.5);
|
|
|
|
| 61 |
m = { top: 22, right: 11, bottom: 56, left: 125 };
|
| 62 |
+
labelFS = 32; imgH = 130; axisXFS = 32; axisYFS = 28; yTitleFS = 28;
|
| 63 |
+
tooltipW = 130; tooltipTop = 24; tooltipRight = 12;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
} else {
|
| 65 |
H = 432;
|
| 66 |
m = { top: 20, right: 11, bottom: 28, left: 85 };
|
| 67 |
+
labelFS = 18; imgH = 100; axisXFS = 16; axisYFS = 14; yTitleFS = 12;
|
| 68 |
+
tooltipW = 200; tooltipTop = 28; tooltipRight = 120;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
}
|
| 71 |
|
| 72 |
let x: ((n: number) => number) | null = null;
|
| 73 |
let y: ((n: number) => number) | null = null;
|
|
|
|
|
|
|
| 74 |
let pointerT: number | null = null;
|
| 75 |
|
| 76 |
const CROISSANT = "/croissant.png";
|
| 77 |
+
const RICE = "/rice.png";
|
| 78 |
const ANTISPIKE = "/antispike.png";
|
| 79 |
+
$: topImg = food === 'croissant' ? CROISSANT : RICE;
|
| 80 |
|
| 81 |
onMount(async () => {
|
| 82 |
updateResponsive();
|
|
|
|
| 86 |
rows = csvParse(text, d => ({
|
| 87 |
time_min: +d['time_min']!,
|
| 88 |
croissant_mgdl_delta: +d['croissant_mgdl_delta']!,
|
| 89 |
+
croissant_with_anti_spike_mgdl_delta: +d['croissant_with_anti_spike_mgdl_delta']!,
|
| 90 |
+
white_rice_mgdl_delta: +d['white_rice_mgdl_delta']!,
|
| 91 |
+
white_rice_with_anti_spike_mgdl_delta: +d['white_rice_with_anti_spike_mgdl_delta']!
|
| 92 |
})) as unknown as Row[];
|
| 93 |
|
| 94 |
loaded = true;
|
|
|
|
| 96 |
|
| 97 |
onDestroy(() => window.removeEventListener('resize', updateResponsive));
|
| 98 |
|
| 99 |
+
/** -------- Accessors (react to toggle) ----------------------------- **/
|
| 100 |
+
const topAcc = (r: Row) =>
|
| 101 |
+
food === 'croissant' ? r.croissant_mgdl_delta : r.white_rice_mgdl_delta;
|
| 102 |
+
const botAcc = (r: Row) =>
|
| 103 |
+
food === 'croissant' ? r.croissant_with_anti_spike_mgdl_delta : r.white_rice_with_anti_spike_mgdl_delta;
|
| 104 |
+
|
| 105 |
/** -------- Scales --------------------------------------------------- **/
|
| 106 |
$: if (loaded) {
|
| 107 |
const xDomain = extent(rows, d => d.time_min) as [number, number];
|
| 108 |
+
const yMin = Math.min(...rows.map(topAcc), ...rows.map(botAcc));
|
| 109 |
+
const yMax = Math.max(...rows.map(topAcc), ...rows.map(botAcc));
|
| 110 |
x = scaleLinear().domain(xDomain).range([m.left, m.left + (w - m.left - m.right)]);
|
| 111 |
y = scaleLinear().domain([yMin - 5, yMax + 5]).range([H - m.bottom, m.top]);
|
| 112 |
}
|
|
|
|
| 114 |
const X = (t: number) => (x ? x(t) : t);
|
| 115 |
const Y = (v: number) => (y ? y(v) : v);
|
| 116 |
|
|
|
|
| 117 |
const makePath = (acc: (r: Row) => number) =>
|
| 118 |
d3line<Row>().x(d => X(d.time_min)).y(d => Y(acc(d))).curve(curveMonotoneX);
|
| 119 |
const makeArea = (acc: (r: Row) => number) =>
|
|
|
|
| 122 |
let pathTop = '', pathBot = '', areaTop = '', areaBot = '';
|
| 123 |
$: if (loaded) {
|
| 124 |
const windowed = rows.filter(d => d.time_min >= 0 && d.time_min <= 100);
|
| 125 |
+
pathTop = makePath(topAcc)(rows) ?? '';
|
| 126 |
+
pathBot = makePath(botAcc)(rows) ?? '';
|
| 127 |
+
areaTop = makeArea(topAcc)(windowed) ?? '';
|
| 128 |
+
areaBot = makeArea(botAcc)(windowed) ?? '';
|
| 129 |
}
|
| 130 |
|
|
|
|
| 131 |
const rightAt = bisector<Row, number>(d => d.time_min).right;
|
| 132 |
function yAtTime(acc: (r: Row) => number, t: number) {
|
| 133 |
const i = Math.max(0, Math.min(rows.length - 2, rightAt(rows, t) - 1));
|
|
|
|
| 152 |
|
| 153 |
$: if (loaded && pointerT !== null && y) {
|
| 154 |
markerX = X(pointerT);
|
| 155 |
+
topVal = yAtTime(topAcc, pointerT);
|
| 156 |
+
botVal = yAtTime(botAcc, pointerT);
|
| 157 |
topY = Y(topVal);
|
| 158 |
botY = Y(botVal);
|
| 159 |
} else {
|
|
|
|
| 172 |
: "At baseline";
|
| 173 |
|
| 174 |
const msgBot = (v: number) =>
|
| 175 |
+
v >= 22 ? "Reduced spike vs alone"
|
| 176 |
: v >= 12 ? "Smaller spike → steadier energy"
|
| 177 |
: v <= 2 && v >= -2 ? "Near baseline ✅"
|
| 178 |
: v < -1 ? "Gentle dip, then recovery"
|
|
|
|
| 180 |
|
| 181 |
$: topMsgText = topVal !== null ? msgTop(topVal) : "";
|
| 182 |
$: botMsgText = botVal !== null ? msgBot(botVal) : "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
|
|
|
| 184 |
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
|
| 185 |
+
$: yTitleX = xTickCol - 6;
|
| 186 |
$: yTitleY = (loaded && y)
|
| 187 |
? (Y(60) - (yTitleFS * 2 + (isMobile ? -110 : -70)))
|
| 188 |
: plotY + 6;
|
|
|
|
| 199 |
<br> <i>Hint: it's interactive so feel free to hover over. </i>
|
| 200 |
</div>
|
| 201 |
|
| 202 |
+
<!-- Toggle buttons -->
|
| 203 |
+
<div class="switcher" role="tablist" aria-label="Choose food">
|
| 204 |
+
<button class:selected={food==='croissant'}
|
| 205 |
+
aria-selected={food==='croissant'}
|
| 206 |
+
on:click={() => (food='croissant')}>
|
| 207 |
+
Croissant
|
| 208 |
+
</button>
|
| 209 |
+
<button class:selected={food==='rice'}
|
| 210 |
+
aria-selected={food==='rice'}
|
| 211 |
+
on:click={() => (food='rice')}>
|
| 212 |
+
Rice
|
| 213 |
+
</button>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
<!-- =================== TOP: Selected food =================== -->
|
| 217 |
<div class="card" style="position:relative; margin-bottom:14px;">
|
| 218 |
{#if loaded}
|
| 219 |
<svg viewBox={`0 0 ${w} ${H}`} style="width:100%;height:auto;touch-action:none;display:block"
|
| 220 |
on:pointermove={onPtr} on:pointerdown={onPtr} on:pointerleave={onLeave}>
|
| 221 |
|
|
|
|
| 222 |
<rect x={plotX} y={plotY} width={plotW} height={Y(30) - plotY} fill="rgba(223,64,133,0.12)" />
|
| 223 |
<rect x={plotX} y={Y(30)} width={plotW} height={plotBottom - Y(30)} fill="rgba(40,200,120,0.14)" />
|
| 224 |
|
|
|
|
| 225 |
<text x={yTitleX} y={yTitleY} fill="#4a4f93" font-size={yTitleFS} text-anchor="end" dominant-baseline="text-before-edge">
|
| 226 |
+
glucose <tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
|
|
|
| 227 |
</text>
|
| 228 |
|
| 229 |
+
<!-- Dynamic callout -->
|
| 230 |
+
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>{topLabel}</text>
|
| 231 |
+
<image href={topImg} x={m.left + leftInset} y={plotY + calloutTop + labelFS + 6} height={imgH} preserveAspectRatio="xMinYMin meet" />
|
| 232 |
|
|
|
|
| 233 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 234 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
| 235 |
|
| 236 |
+
{#each [{t:0,label:"eating time"},{t:120,label:"2 hours later"}] as xl}
|
| 237 |
+
<text x={X(xl.t)} y={(H - m.bottom) + Math.min(m.bottom - 6, Math.round(axisXFS * 0.9))}
|
| 238 |
+
text-anchor="middle" fill="#4a4f93" font-size={axisXFS}>{xl.label}</text>
|
| 239 |
{/each}
|
| 240 |
{#each xTicksVals as t}
|
| 241 |
<line x1={X(t)} x2={X(t)} y1={plotBottom - xTickLen} y2={plotBottom} stroke="#4a4f93" stroke-width="2" />
|
| 242 |
{/each}
|
| 243 |
|
|
|
|
| 244 |
<text x={xTickCol} y={Y(60) + yOff60} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+60</text>
|
| 245 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 246 |
+
<text x={xTickCol} y={Y(0) + yOff0 } fill="#4a4f93" font-size={axisYFS} text-anchor="end">baseline</text>
|
| 247 |
|
|
|
|
| 248 |
<defs>
|
| 249 |
<clipPath id="clipTopAboveBaseline">
|
| 250 |
<rect x={plotX} y={plotY} width={plotW} height={Y(0) - plotY} />
|
|
|
|
| 262 |
|
| 263 |
{#if topVal !== null}
|
| 264 |
<div class="tooltip" style={`top:${tooltipTop}px; right:${tooltipRight}px; left:auto; width:${tooltipW}px;`}>
|
| 265 |
+
<div style="font-weight:700; color:#111; font-size:14px; margin-bottom:6px;">
|
| 266 |
{topMsgText}
|
| 267 |
</div>
|
| 268 |
<div style="display:flex;justify-content:space-between;gap:12px;">
|
| 269 |
+
<div>{topLabel}</div>
|
| 270 |
<strong>{topVal.toFixed(0)} mg/dL</strong>
|
| 271 |
</div>
|
| 272 |
</div>
|
|
|
|
| 283 |
<rect x={plotX} y={plotY} width={plotW} height={Y(30) - plotY} fill="rgba(223,64,133,0.12)" />
|
| 284 |
<rect x={plotX} y={Y(30)} width={plotW} height={plotBottom - Y(30)} fill="rgba(40,200,120,0.14)" />
|
| 285 |
|
|
|
|
| 286 |
<text x={yTitleX} y={yTitleY} fill="#4a4f93" font-size={yTitleFS} text-anchor="end" dominant-baseline="text-before-edge">
|
| 287 |
+
glucose <tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
|
|
|
| 288 |
</text>
|
| 289 |
|
| 290 |
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>With Anti-Spike</text>
|
|
|
|
| 293 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 294 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
| 295 |
|
| 296 |
+
{#each [{t:0,label:"eating time"},{t:120,label:"2 hours later"}] as xl}
|
| 297 |
+
<text x={X(xl.t)} y={(H - m.bottom) + Math.min(m.bottom - 6, Math.round(axisXFS * 0.9))}
|
| 298 |
+
text-anchor="middle" fill="#4a4f93" font-size={axisXFS}>{xl.label}</text>
|
| 299 |
{/each}
|
| 300 |
{#each xTicksVals as t}
|
| 301 |
<line x1={X(t)} x2={X(t)} y1={plotBottom - xTickLen} y2={plotBottom} stroke="#4a4f93" stroke-width="2" />
|
| 302 |
{/each}
|
| 303 |
|
|
|
|
| 304 |
<text x={xTickCol} y={Y(60) + yOff60} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+60</text>
|
| 305 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 306 |
+
<text x={xTickCol} y={Y(0) + yOff0 } fill="#4a4f93" font-size={axisYFS} text-anchor="end">baseline</text>
|
| 307 |
|
| 308 |
<defs>
|
| 309 |
<clipPath id="clipBotAboveBaseline">
|
|
|
|
| 322 |
|
| 323 |
{#if botVal !== null}
|
| 324 |
<div class="tooltip" style={`top:${tooltipTop}px; right:${tooltipRight}px; left:auto; width:${tooltipW}px;`}>
|
| 325 |
+
<div style="font-weight:700; color:#111; font-size:14px; margin-bottom:6px;">
|
| 326 |
{botMsgText}
|
| 327 |
</div>
|
| 328 |
<div style="display:flex;justify-content:space-between;gap:12px;">
|
| 329 |
+
<div>{bottomLabel}</div>
|
| 330 |
<strong>{botVal.toFixed(0)} mg/dL</strong>
|
| 331 |
</div>
|
| 332 |
</div>
|