Spaces:
Running
Running
Update src/App.svelte
Browse files- src/App.svelte +81 -26
src/App.svelte
CHANGED
|
@@ -6,6 +6,7 @@
|
|
| 6 |
import { extent, bisector } from 'd3-array';
|
| 7 |
import { line as d3line, area as d3area, curveMonotoneX } from 'd3-shape';
|
| 8 |
|
|
|
|
| 9 |
type Row = {
|
| 10 |
time_min: number;
|
| 11 |
croissant_mgdl_delta: number;
|
|
@@ -17,62 +18,93 @@
|
|
| 17 |
let rows: Row[] = [];
|
| 18 |
let loaded = false;
|
| 19 |
|
| 20 |
-
/**
|
| 21 |
type Food = 'croissant' | 'rice';
|
| 22 |
-
let food: Food = 'croissant'; // default
|
| 23 |
$: topLabel = food === 'croissant' ? 'Croissant' : 'Rice';
|
| 24 |
$: bottomLabel = `${topLabel} + Anti-Spike`;
|
| 25 |
|
| 26 |
-
/**
|
| 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 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
} else {
|
| 65 |
H = 432;
|
| 66 |
m = { top: 20, right: 11, bottom: 28, left: 85 };
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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";
|
|
@@ -82,6 +114,7 @@
|
|
| 82 |
updateResponsive();
|
| 83 |
window.addEventListener('resize', updateResponsive);
|
| 84 |
|
|
|
|
| 85 |
const text = await (await fetch('/glucosedata2.csv')).text();
|
| 86 |
rows = csvParse(text, d => ({
|
| 87 |
time_min: +d['time_min']!,
|
|
@@ -96,17 +129,21 @@
|
|
| 96 |
|
| 97 |
onDestroy(() => window.removeEventListener('resize', updateResponsive));
|
| 98 |
|
| 99 |
-
/**
|
| 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 |
-
/**
|
| 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,6 +151,7 @@
|
|
| 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,12 +160,14 @@
|
|
| 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));
|
|
@@ -145,7 +185,7 @@
|
|
| 145 |
}
|
| 146 |
function onLeave() { pointerT = null; }
|
| 147 |
|
| 148 |
-
// Tooltip values
|
| 149 |
let markerX: number | null = null;
|
| 150 |
let topVal: number | null = null, botVal: number | null = null;
|
| 151 |
let topY: number | null = null, botY: number | null = null;
|
|
@@ -181,6 +221,7 @@
|
|
| 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)
|
|
@@ -199,40 +240,49 @@
|
|
| 199 |
<br> <i>Hint: it's interactive so feel free to hover over. </i>
|
| 200 |
</div>
|
| 201 |
|
| 202 |
-
<!--
|
| 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 |
-
|
|
|
|
|
|
|
| 208 |
</button>
|
| 209 |
-
<button class:selected={food==='rice'}
|
| 210 |
aria-selected={food==='rice'}
|
| 211 |
-
on:click={() => (food='rice')}
|
| 212 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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>
|
|
@@ -241,10 +291,12 @@
|
|
| 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 }
|
| 247 |
|
|
|
|
| 248 |
<defs>
|
| 249 |
<clipPath id="clipTopAboveBaseline">
|
| 250 |
<rect x={plotX} y={plotY} width={plotW} height={Y(0) - plotY} />
|
|
@@ -283,8 +335,10 @@
|
|
| 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
|
|
|
|
| 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,17 +347,18 @@
|
|
| 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 }
|
| 307 |
|
| 308 |
<defs>
|
| 309 |
<clipPath id="clipBotAboveBaseline">
|
|
|
|
| 6 |
import { extent, bisector } from 'd3-array';
|
| 7 |
import { line as d3line, area as d3area, curveMonotoneX } from 'd3-shape';
|
| 8 |
|
| 9 |
+
/** ---------- Data types (now includes rice columns) ----------------- */
|
| 10 |
type Row = {
|
| 11 |
time_min: number;
|
| 12 |
croissant_mgdl_delta: number;
|
|
|
|
| 18 |
let rows: Row[] = [];
|
| 19 |
let loaded = false;
|
| 20 |
|
| 21 |
+
/** ---------- Toggle state ------------------------------------------- */
|
| 22 |
type Food = 'croissant' | 'rice';
|
| 23 |
+
let food: Food = 'croissant'; // default
|
| 24 |
$: topLabel = food === 'croissant' ? 'Croissant' : 'Rice';
|
| 25 |
$: bottomLabel = `${topLabel} + Anti-Spike`;
|
| 26 |
|
| 27 |
+
/** ---------- Layout -------------------------------------------------- */
|
| 28 |
const w = 960;
|
| 29 |
let H = 432; // desktop height
|
| 30 |
+
|
| 31 |
+
// Add more left white space so y-labels never clip
|
| 32 |
let m = { top: 20, right: 11, bottom: 28, left: 92 };
|
| 33 |
|
| 34 |
+
// Derived plot rect
|
| 35 |
$: plotX = m.left;
|
| 36 |
$: plotY = m.top;
|
| 37 |
$: plotW = w - m.left - m.right;
|
| 38 |
$: plotBottom = H - m.bottom;
|
| 39 |
|
| 40 |
+
// Callouts
|
| 41 |
const leftInset = 54;
|
| 42 |
let labelFS = 18;
|
| 43 |
let imgH = 100;
|
| 44 |
let isMobile = false;
|
| 45 |
$: calloutTop = isMobile ? 26 : 24;
|
| 46 |
|
| 47 |
+
// Axes font sizes
|
| 48 |
let axisXFS = 16;
|
| 49 |
let axisYFS = 14;
|
| 50 |
+
|
| 51 |
+
// y-axis title “glucose (mg/dL)”
|
| 52 |
let yTitleFS = 12;
|
| 53 |
|
| 54 |
+
// Tooltip position
|
| 55 |
let tooltipW = 200;
|
| 56 |
let tooltipTop = 28;
|
| 57 |
let tooltipRight = 120;
|
| 58 |
|
| 59 |
+
// X ticks (inside the band, centered under labels)
|
| 60 |
const xTicksVals = [0, 120];
|
| 61 |
$: xTickLen = 10;
|
| 62 |
+
|
| 63 |
+
// Column where y-tick TEXT sits
|
| 64 |
$: xTickCol = m.left - 12;
|
| 65 |
+
|
| 66 |
+
// Fine vertical nudges for tick text
|
| 67 |
$: yOff60 = 30;
|
| 68 |
$: yOff30 = 6;
|
| 69 |
$: yOff0 = -8;
|
| 70 |
|
| 71 |
function updateResponsive() {
|
| 72 |
isMobile = window.matchMedia('(max-width: 640px)').matches;
|
| 73 |
+
|
| 74 |
if (isMobile) {
|
| 75 |
H = Math.round(432 * 1.5);
|
| 76 |
m = { top: 22, right: 11, bottom: 56, left: 125 };
|
| 77 |
+
|
| 78 |
+
labelFS = 32;
|
| 79 |
+
imgH = 130;
|
| 80 |
+
axisXFS = 32;
|
| 81 |
+
axisYFS = 28;
|
| 82 |
+
yTitleFS = 28;
|
| 83 |
+
tooltipW = 130;
|
| 84 |
+
tooltipTop = 24;
|
| 85 |
+
tooltipRight = 12;
|
| 86 |
} else {
|
| 87 |
H = 432;
|
| 88 |
m = { top: 20, right: 11, bottom: 28, left: 85 };
|
| 89 |
+
|
| 90 |
+
labelFS = 18;
|
| 91 |
+
imgH = 100;
|
| 92 |
+
axisXFS = 16;
|
| 93 |
+
axisYFS = 14;
|
| 94 |
+
yTitleFS = 12;
|
| 95 |
+
tooltipW = 200;
|
| 96 |
+
tooltipTop = 28;
|
| 97 |
+
tooltipRight = 120;
|
| 98 |
}
|
| 99 |
}
|
| 100 |
|
| 101 |
let x: ((n: number) => number) | null = null;
|
| 102 |
let y: ((n: number) => number) | null = null;
|
| 103 |
+
|
| 104 |
+
// Pointer state
|
| 105 |
let pointerT: number | null = null;
|
| 106 |
|
| 107 |
+
// Images
|
| 108 |
const CROISSANT = "/croissant.png";
|
| 109 |
const RICE = "/rice.png";
|
| 110 |
const ANTISPIKE = "/antispike.png";
|
|
|
|
| 114 |
updateResponsive();
|
| 115 |
window.addEventListener('resize', updateResponsive);
|
| 116 |
|
| 117 |
+
// IMPORTANT: glucosedata2.csv must include the two rice columns below.
|
| 118 |
const text = await (await fetch('/glucosedata2.csv')).text();
|
| 119 |
rows = csvParse(text, d => ({
|
| 120 |
time_min: +d['time_min']!,
|
|
|
|
| 129 |
|
| 130 |
onDestroy(() => window.removeEventListener('resize', updateResponsive));
|
| 131 |
|
| 132 |
+
/** ---------- Accessors that change with the toggle ------------------- */
|
| 133 |
const topAcc = (r: Row) =>
|
| 134 |
food === 'croissant' ? r.croissant_mgdl_delta : r.white_rice_mgdl_delta;
|
| 135 |
const botAcc = (r: Row) =>
|
| 136 |
food === 'croissant' ? r.croissant_with_anti_spike_mgdl_delta : r.white_rice_with_anti_spike_mgdl_delta;
|
| 137 |
|
| 138 |
+
/** ---------- Scales (reactive to data + toggle) --------------------- */
|
| 139 |
$: if (loaded) {
|
| 140 |
const xDomain = extent(rows, d => d.time_min) as [number, number];
|
| 141 |
+
|
| 142 |
+
// Use the currently-selected food columns for y extents,
|
| 143 |
+
// so the range adjusts correctly when switching.
|
| 144 |
const yMin = Math.min(...rows.map(topAcc), ...rows.map(botAcc));
|
| 145 |
const yMax = Math.max(...rows.map(topAcc), ...rows.map(botAcc));
|
| 146 |
+
|
| 147 |
x = scaleLinear().domain(xDomain).range([m.left, m.left + (w - m.left - m.right)]);
|
| 148 |
y = scaleLinear().domain([yMin - 5, yMax + 5]).range([H - m.bottom, m.top]);
|
| 149 |
}
|
|
|
|
| 151 |
const X = (t: number) => (x ? x(t) : t);
|
| 152 |
const Y = (v: number) => (y ? y(v) : v);
|
| 153 |
|
| 154 |
+
// Paths + filled area (only 0..100 and only above baseline)
|
| 155 |
const makePath = (acc: (r: Row) => number) =>
|
| 156 |
d3line<Row>().x(d => X(d.time_min)).y(d => Y(acc(d))).curve(curveMonotoneX);
|
| 157 |
const makeArea = (acc: (r: Row) => number) =>
|
|
|
|
| 160 |
let pathTop = '', pathBot = '', areaTop = '', areaBot = '';
|
| 161 |
$: if (loaded) {
|
| 162 |
const windowed = rows.filter(d => d.time_min >= 0 && d.time_min <= 100);
|
| 163 |
+
// These react to `food` because topAcc/botAcc do.
|
| 164 |
pathTop = makePath(topAcc)(rows) ?? '';
|
| 165 |
pathBot = makePath(botAcc)(rows) ?? '';
|
| 166 |
areaTop = makeArea(topAcc)(windowed) ?? '';
|
| 167 |
areaBot = makeArea(botAcc)(windowed) ?? '';
|
| 168 |
}
|
| 169 |
|
| 170 |
+
// Interpolation
|
| 171 |
const rightAt = bisector<Row, number>(d => d.time_min).right;
|
| 172 |
function yAtTime(acc: (r: Row) => number, t: number) {
|
| 173 |
const i = Math.max(0, Math.min(rows.length - 2, rightAt(rows, t) - 1));
|
|
|
|
| 185 |
}
|
| 186 |
function onLeave() { pointerT = null; }
|
| 187 |
|
| 188 |
+
// Tooltip values (reactive)
|
| 189 |
let markerX: number | null = null;
|
| 190 |
let topVal: number | null = null, botVal: number | null = null;
|
| 191 |
let topY: number | null = null, botY: number | null = null;
|
|
|
|
| 221 |
$: topMsgText = topVal !== null ? msgTop(topVal) : "";
|
| 222 |
$: botMsgText = botVal !== null ? msgBot(botVal) : "";
|
| 223 |
|
| 224 |
+
// y-axis title ABOVE +60 and to the LEFT of the +60 label
|
| 225 |
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
|
| 226 |
$: yTitleX = xTickCol - 6;
|
| 227 |
$: yTitleY = (loaded && y)
|
|
|
|
| 240 |
<br> <i>Hint: it's interactive so feel free to hover over. </i>
|
| 241 |
</div>
|
| 242 |
|
| 243 |
+
<!-- ========= IMAGE TOGGLE (croissant / rice) ========================= -->
|
| 244 |
+
<div class="switcher imgs" role="tablist" aria-label="Choose food">
|
| 245 |
+
<button class="imgbtn" class:selected={food==='croissant'}
|
| 246 |
aria-selected={food==='croissant'}
|
| 247 |
+
on:click={() => (food='croissant')}
|
| 248 |
+
title="Croissant" aria-label="Croissant">
|
| 249 |
+
<img src="/croissant.png" alt="Croissant"/>
|
| 250 |
+
<span>Croissant</span>
|
| 251 |
</button>
|
| 252 |
+
<button class="imgbtn" class:selected={food==='rice'}
|
| 253 |
aria-selected={food==='rice'}
|
| 254 |
+
on:click={() => (food='rice')}
|
| 255 |
+
title="Rice" aria-label="Rice">
|
| 256 |
+
<img src="/rice.png" alt="Rice"/>
|
| 257 |
+
<span>Rice</span>
|
| 258 |
</button>
|
| 259 |
</div>
|
| 260 |
|
| 261 |
+
<!-- =================== TOP: Selected food (alone) =================== -->
|
| 262 |
<div class="card" style="position:relative; margin-bottom:14px;">
|
| 263 |
{#if loaded}
|
| 264 |
<svg viewBox={`0 0 ${w} ${H}`} style="width:100%;height:auto;touch-action:none;display:block"
|
| 265 |
on:pointermove={onPtr} on:pointerdown={onPtr} on:pointerleave={onLeave}>
|
| 266 |
|
| 267 |
+
<!-- Bands inside plot box -->
|
| 268 |
<rect x={plotX} y={plotY} width={plotW} height={Y(30) - plotY} fill="rgba(223,64,133,0.12)" />
|
| 269 |
<rect x={plotX} y={Y(30)} width={plotW} height={plotBottom - Y(30)} fill="rgba(40,200,120,0.14)" />
|
| 270 |
|
| 271 |
+
<!-- y-axis title -->
|
| 272 |
<text x={yTitleX} y={yTitleY} fill="#4a4f93" font-size={yTitleFS} text-anchor="end" dominant-baseline="text-before-edge">
|
| 273 |
+
glucose
|
| 274 |
+
<tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
| 275 |
</text>
|
| 276 |
|
| 277 |
<!-- Dynamic callout -->
|
| 278 |
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>{topLabel}</text>
|
| 279 |
<image href={topImg} x={m.left + leftInset} y={plotY + calloutTop + labelFS + 6} height={imgH} preserveAspectRatio="xMinYMin meet" />
|
| 280 |
|
| 281 |
+
<!-- Guides -->
|
| 282 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 283 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
| 284 |
|
| 285 |
+
<!-- X labels + inside ticks -->
|
| 286 |
{#each [{t:0,label:"eating time"},{t:120,label:"2 hours later"}] as xl}
|
| 287 |
<text x={X(xl.t)} y={(H - m.bottom) + Math.min(m.bottom - 6, Math.round(axisXFS * 0.9))}
|
| 288 |
text-anchor="middle" fill="#4a4f93" font-size={axisXFS}>{xl.label}</text>
|
|
|
|
| 291 |
<line x1={X(t)} x2={X(t)} y1={plotBottom - xTickLen} y2={plotBottom} stroke="#4a4f93" stroke-width="2" />
|
| 292 |
{/each}
|
| 293 |
|
| 294 |
+
<!-- Y ticks in LEFT WHITE MARGIN -->
|
| 295 |
<text x={xTickCol} y={Y(60) + yOff60} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+60</text>
|
| 296 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 297 |
+
<text x={xTickCol} y={Y(0) + yOff0 } fill="#4a4f93" font-size={axisYFS} text-anchor="end">baseline</text>
|
| 298 |
|
| 299 |
+
<!-- Clip above baseline -->
|
| 300 |
<defs>
|
| 301 |
<clipPath id="clipTopAboveBaseline">
|
| 302 |
<rect x={plotX} y={plotY} width={plotW} height={Y(0) - plotY} />
|
|
|
|
| 335 |
<rect x={plotX} y={plotY} width={plotW} height={Y(30) - plotY} fill="rgba(223,64,133,0.12)" />
|
| 336 |
<rect x={plotX} y={Y(30)} width={plotW} height={plotBottom - Y(30)} fill="rgba(40,200,120,0.14)" />
|
| 337 |
|
| 338 |
+
<!-- y-axis title ABOVE +60 and LEFT of tick labels -->
|
| 339 |
<text x={yTitleX} y={yTitleY} fill="#4a4f93" font-size={yTitleFS} text-anchor="end" dominant-baseline="text-before-edge">
|
| 340 |
+
glucose
|
| 341 |
+
<tspan x={yTitleX} dy={yTitleDy} text-anchor="end">(mg/dL)</tspan>
|
| 342 |
</text>
|
| 343 |
|
| 344 |
<text x={m.left + leftInset} y={plotY + calloutTop} fill="#111" font-weight="700" font-size={labelFS}>With Anti-Spike</text>
|
|
|
|
| 347 |
<line x1={plotX} x2={plotX + plotW} y1={Y(0)} y2={Y(0)} stroke="#cfd6ff" stroke-width="2" />
|
| 348 |
<line x1={plotX} x2={plotX + plotW} y1={Y(30)} y2={Y(30)} stroke="#f8b9d0" stroke-dasharray="6 6" />
|
| 349 |
|
| 350 |
+
{#each xTicksVals as t}
|
| 351 |
+
<line x1={X(t)} x2={X(t)} y1={plotBottom - xTickLen} y2={plotBottom} stroke="#4a4f93" stroke-width="2" />
|
| 352 |
+
{/each}
|
| 353 |
{#each [{t:0,label:"eating time"},{t:120,label:"2 hours later"}] as xl}
|
| 354 |
<text x={X(xl.t)} y={(H - m.bottom) + Math.min(m.bottom - 6, Math.round(axisXFS * 0.9))}
|
| 355 |
text-anchor="middle" fill="#4a4f93" font-size={axisXFS}>{xl.label}</text>
|
| 356 |
{/each}
|
|
|
|
|
|
|
|
|
|
| 357 |
|
| 358 |
+
<!-- Y ticks in LEFT WHITE MARGIN -->
|
| 359 |
<text x={xTickCol} y={Y(60) + yOff60} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+60</text>
|
| 360 |
<text x={xTickCol} y={Y(30) + yOff30} fill="#4a4f93" font-size={axisYFS} text-anchor="end">+30</text>
|
| 361 |
+
<text x={xTickCol} y={Y(0) + yOff0 } fill="#4a4f93" font-size={axisYFS} text-anchor="end">baseline</text>
|
| 362 |
|
| 363 |
<defs>
|
| 364 |
<clipPath id="clipBotAboveBaseline">
|