// ==================== Baby Tracker Charts ====================
window.BabyCharts = (() => {
'use strict';
const _charts = new Map(); // domId → { chart, handler }
let _sharedResizeHandler = null;
let _resizeTimer = null;
let _echartsReady = null;
/** Lazy-load ECharts from CDN — only when growth tab is first opened */
function ensureECharts() {
if (_echartsReady) return _echartsReady;
if (typeof echarts !== 'undefined') {
_echartsReady = Promise.resolve(true);
return _echartsReady;
}
_echartsReady = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js';
script.onload = () => resolve(true);
script.onerror = () => reject(new Error('ECharts 加载失败,请检查网络'));
document.head.appendChild(script);
});
return _echartsReady;
}
function ensureSharedResize() {
if (_sharedResizeHandler) return;
_sharedResizeHandler = () => {
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(() => {
for (const { chart } of _charts.values()) {
try { chart.resize(); } catch { /* disposed */ }
}
}, 150);
};
window.addEventListener('resize', _sharedResizeHandler);
}
function removeSharedResizeIfEmpty() {
if (_charts.size === 0 && _sharedResizeHandler) {
window.removeEventListener('resize', _sharedResizeHandler);
_sharedResizeHandler = null;
clearTimeout(_resizeTimer);
_resizeTimer = null;
}
}
async function renderChart(domId, title, userPoints, standardKey, yRange) {
const el = document.getElementById(domId);
if (!el) return;
await ensureECharts();
if (typeof echarts === 'undefined') return;
disposeByDomId(domId);
const chart = echarts.init(el, null, { renderer: 'canvas' });
// Read colors from CSS custom properties so dark mode picks up automatically
const style = getComputedStyle(document.documentElement);
const babyColor = style.getPropertyValue('--accent').trim() || '#D4896A';
const inkMuted = style.getPropertyValue('--muted').trim() || '#8A8580';
const gridLine = style.getPropertyValue('--line-soft').trim() || '#EFEAE3';
const inkColor = style.getPropertyValue('--ink').trim() || '#2C2A28';
const surfaceColor = style.getPropertyValue('--surface').trim() || '#FFFFFF';
const option = {
backgroundColor: 'transparent',
title: {
text: title,
left: '8px',
top: '8px',
textStyle: { fontSize: 13, fontWeight: 650, color: inkColor }
},
grid: { top: 46, right: 16, bottom: 28, left: 36, containLabel: false },
tooltip: {
trigger: 'axis',
confine: true,
backgroundColor: inkColor,
borderWidth: 0,
textStyle: { color: surfaceColor, fontSize: 12 },
formatter(params) {
const userItem = params.find(p => p.seriesName === '宝宝数据');
if (!userItem || !userItem.value) return '';
const [age, val] = userItem.value;
return `月龄 ${Number(age).toFixed(1)} 月
${val}`;
}
},
xAxis: {
type: 'value',
min: 0,
max: 24,
interval: 3,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { formatter: '{value}月', color: inkMuted, fontSize: 11 }
},
yAxis: {
type: 'value',
min: yRange
? (value) => Math.floor(Math.min(value.min, yRange[0]) * 0.95)
: undefined,
scale: true,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { type: 'dashed', color: gridLine } },
axisLabel: { color: inkMuted, fontSize: 11 }
},
series: [
...BabyUtils.generateStandardSeries(standardKey),
{
name: '宝宝数据',
type: 'line',
data: userPoints,
smooth: false,
symbol: 'circle',
symbolSize: 7,
itemStyle: { color: babyColor, borderColor: surfaceColor, borderWidth: 2 },
lineStyle: { color: babyColor, width: 2.5 },
z: 10,
markPoint: userPoints.length
? {
data: [{
coord: userPoints[userPoints.length - 1],
symbolSize: 1,
label: {
show: true,
position: 'top',
formatter: () => String(userPoints[userPoints.length - 1][1]),
color: babyColor,
fontWeight: 700,
fontSize: 12
}
}]
}
: undefined
}
]
};
chart.setOption(option);
ensureSharedResize();
_charts.set(domId, { chart, handler: null });
}
/** Helper: read CSS variable with fallback */
function _cssVar(name, fallback) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
/** Render a bar chart (used by report tab) */
async function renderBarChart(domId, title, labels, values) {
const el = document.getElementById(domId);
if (!el) return;
await ensureECharts();
if (typeof echarts === 'undefined') return;
disposeByDomId(domId);
const accentColor = _cssVar('--accent', '#D4896A');
const inkColor = _cssVar('--ink', '#2C2A28');
const mutedColor = _cssVar('--muted', '#8A8580');
const surfaceColor = _cssVar('--surface', '#FFFFFF');
const lineColor = _cssVar('--line-soft', '#F0EBE3');
const chart = echarts.init(el, null, { renderer: 'canvas' });
chart.setOption({
backgroundColor: 'transparent',
title: {
text: title, left: '8px', top: '8px',
textStyle: { fontSize: 13, fontWeight: 650, color: inkColor }
},
grid: { top: 44, right: 16, bottom: 32, left: 44, containLabel: false },
tooltip: {
trigger: 'axis', confine: true,
backgroundColor: inkColor, borderWidth: 0,
textStyle: { color: surfaceColor, fontSize: 12 }
},
xAxis: {
type: 'category', data: labels,
axisLine: { show: false }, axisTick: { show: false },
axisLabel: { color: mutedColor, fontSize: 10, rotate: labels.length > 10 ? 45 : 0 }
},
yAxis: {
type: 'value',
axisLine: { show: false }, axisTick: { show: false },
splitLine: { lineStyle: { type: 'dashed', color: lineColor } },
axisLabel: { color: mutedColor, fontSize: 11 }
},
series: [{
type: 'bar', data: values,
itemStyle: { color: accentColor, borderRadius: [8, 8, 0, 0] },
barMaxWidth: 40
}]
});
ensureSharedResize();
_charts.set(domId, { chart, handler: null });
}
/** Render a donut/pie chart (used by report tab) */
async function renderDonutChart(domId, title, data) {
const el = document.getElementById(domId);
if (!el) return;
await ensureECharts();
if (typeof echarts === 'undefined') return;
disposeByDomId(domId);
const inkColor = _cssVar('--ink', '#2C2A28');
const surfaceColor = _cssVar('--surface', '#FFFFFF');
const mutedColor = _cssVar('--muted', '#8A8580');
const palette = [
_cssVar('--milk', '#6B9AC4'),
_cssVar('--accent', '#D4896A'),
_cssVar('--food', '#D4A574'),
_cssVar('--meds', '#9B8EC4'),
_cssVar('--poo', '#7D9B76')
];
const chart = echarts.init(el, null, { renderer: 'canvas' });
chart.setOption({
backgroundColor: 'transparent',
title: {
text: title, left: '8px', top: '8px',
textStyle: { fontSize: 13, fontWeight: 650, color: inkColor }
},
tooltip: {
trigger: 'item', confine: true,
backgroundColor: inkColor, borderWidth: 0,
textStyle: { color: surfaceColor, fontSize: 12 },
formatter: (p) => `${p.name}: ${p.value}ml (${p.percent}%)`
},
legend: {
bottom: 8,
textStyle: { color: mutedColor, fontSize: 11 },
itemWidth: 10, itemHeight: 10, itemGap: 16
},
series: [{
type: 'pie',
radius: ['55%', '78%'],
center: ['50%', '46%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: surfaceColor, borderWidth: 2 },
label: {
show: true, position: 'outside',
formatter: '{b}\n{d}%', color: mutedColor, fontSize: 11
},
emphasis: { label: { fontSize: 14, fontWeight: 'bold' } },
data: data,
color: palette
}]
});
ensureSharedResize();
_charts.set(domId, { chart, handler: null });
}
function disposeByDomId(domId) {
const entry = _charts.get(domId);
if (!entry) return;
try { entry.chart.dispose(); } catch { /* already disposed */ }
_charts.delete(domId);
removeSharedResizeIfEmpty();
}
function disposeAll() {
for (const { chart } of _charts.values()) {
try { chart.dispose(); } catch { /* already disposed */ }
}
_charts.clear();
removeSharedResizeIfEmpty();
}
/** Re-render all charts (e.g. after tab becomes visible again) */
function resizeAll() {
for (const { chart } of _charts.values()) {
try { chart.resize(); } catch { /* disposed */ }
}
}
return {
ensureECharts,
renderChart,
renderBarChart,
renderDonutChart,
disposeByDomId,
disposeAll,
resizeAll
};
})();