利用 数据库 和 Chart(图表) 做小朋友身高管理

实现功能

  • 数据库不定期录入小朋友身高
  • Chart 会自动刷新显示最新数据
  • 灰色的线为参考线:0~17 岁男生标准身高数据(数据来源:中国儿童生长标准)
  • 蓝色的线为小朋友实际身高数据
  • 鼠标放置在数据点上会显示具体值
  • x 轴可以滚动缩放
  • x 轴起始点为小朋友生日

初始配置

在 Chart 代码中配置小朋友生日、数据库 id、图表块 id

// 小朋友的生日(格式:YYYY-MM-DD) const birthday = new Date('2018-01-10'); // 关联的数据库块id,这里的id根据需要改成自己的 const avBlockId = '20250224163226-2d2uu1l'; // 关联的图表块id,这里的id根据需要改成自己的 const chartBlockId = '20250225112501-5gze0yo';

显示效果

image.png

image.png

image.png

Chart 代码

(async () => { // 小朋友的生日(格式:YYYY-MM-DD) const birthday = new Date('2018-01-10'); // 关联的数据库块id,这里的id根据需要改成自己的 const avBlockId = '20250224163226-2d2uu1l'; // 关联的图表块id,这里的id根据需要改成自己的 const chartBlockId = '20250225112501-5gze0yo'; // 自动刷新延迟,单位是毫秒,默认是1秒,0则不自动刷新 const autoFreshDelay = 1000; // 实际测量数据(将在获取数据库后初始化) let actualMeasurements = []; // 生成严格按年递增的基准日期(基于生日每年递增) const baseDates = Array.from({length: 18}, (_, age) => { const date = new Date(birthday.getFullYear() + age, birthday.getMonth(), birthday.getDate()); date.setHours(0, 0, 0, 0); // 验证日期生成(生日年份+age年) console.assert(date.getFullYear() === birthday.getFullYear() + age, `年龄${age}周岁应对应${birthday.getFullYear() + age}年,实际得到:${date.toISOString()}`); return date; }); // 生成中国男生标准身高数据(0-17周岁)并确保日期对应 // 数据来源:《中国17岁以下儿童生长发育参照标准》和《中国学龄儿童青少年生长发育参照标准》 const standardData = [50.4,76.5,88.5,96.8,104.1,111.3,117.7,124.0,130.0,135.4,140.2,145.3,151.9,159.5,165.9,169.8,171.6,172.3] .map((height, age) => ({ date: baseDates[age].getTime(), // 使用时间戳格式 height: height, age: age })); // 生成时间轴数据(基于生日生成未来17年的日期) const xAxisData = baseDates.map(date => date.getTime()); // 转换标准身高数据为时间戳格式(保留完整数据对象) const standardSeriesData = standardData.map(item => ({ name: `${item.age}周岁`, value: [item.date, item.height], age: item.age, height: item.height, date: item.date })); let option = { title: { text: '儿童身高成长曲线 (0-17岁)', subtext: '数据来源:中国儿童生长标准', left: 'center', top: 20, textStyle: { fontSize: 18 }, subtextStyle: { fontSize: 12 } }, tooltip: { trigger: 'item', formatter: function(params) { if (params.seriesName === '标准身高') { const age = Number( params.data?.age ?? params.encode?.tooltip?.[0] ?? params.name.match(/\d+/)?.[0] ?? new Date(params.value[0]).getFullYear() - birthday.getFullYear() ); const height = Number( params.data?.height ?? params.encode?.tooltip?.[1] ?? params.value[1] ?? standardData[age]?.height ); return `年龄:${age}周岁<br/>标准身高:${height}cm`; } // 最终解决方案:使用标准ECharts数据访问方式 try { // 直接通过value数组获取数据点值 // 增强数组类型检查和解构容错处理 // 增强类型检查和处理非数组情况 // 最终修复方案:安全处理所有数据类型 let rawValue; if (Array.isArray(params.value)) { rawValue = params.value; } else if (typeof params.value === 'object' && params.value !== null) { // 处理可能的数据对象格式 rawValue = [params.value.timestamp || params.value.date, params.value.height]; } else { // 处理原始数值类型 rawValue = [Date.now(), params.value]; } const isValueValid = rawValue.length >= 2 && !isNaN(rawValue[0]) && !isNaN(rawValue[1]); // 调试日志增强 console.debug('工具提示数据详情:', { seriesType: params.seriesType, dataType: typeof params.value, value: params.value, componentType: params.componentType, dimensionNames: params.dimensionNames }); const timestamp = isValueValid ? rawValue[0] : null; const height = isValueValid ? rawValue[1] : null; // 记录原始数据用于调试 console.debug('工具提示原始数据:', { series: params.seriesName, valueType: typeof rawValue, value: rawValue, isValid: isValueValid }); // 增强类型检查 const validTimestamp = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp); const validHeight = typeof height === 'number' ? height : Number(height); // 使用更安全的日期格式化 const dateStr = Number.isFinite(validTimestamp) ? echarts.time.format(validTimestamp, '{yyyy}-{MM}-{dd}', false) : '日期无效'; // 处理身高显示 const heightDisplay = Number.isFinite(validHeight) ? `${validHeight.toFixed(1)}cm` : '身高数据异常'; return `测量时间:${dateStr}<br/>实际身高:${heightDisplay}`; } catch (e) { console.error('工具提示处理失败:', e); return `数据解析错误:${e.message}`; } // 验证数值有效性 if (isNaN(timestamp) || isNaN(height)) { console.error('无效数据点:', JSON.stringify({ rawTimestamp, rawHeight, timestamp, height })); return '数据格式异常'; } // 创建日期对象并验证 const actualDate = new Date(timestamp); if (isNaN(actualDate.getTime())) { console.error('无效时间戳:', JSON.stringify({ timestamp, isoString: new Date(timestamp).toISOString() })); return '日期转换异常'; } // 格式化日期组件 const year = actualDate.getFullYear(); const month = `${actualDate.getMonth() + 1}`.padStart(2, '0'); const day = `${actualDate.getDate()}`.padStart(2, '0'); if (isNaN(timestamp) || isNaN(height)) { console.error('无效数据点:', JSON.stringify({ timestamp, height })); return '数据格式异常'; } const isValidDate = !isNaN(actualDate.getTime()); if (!isValidDate) { console.error('Invalid timestamp:', timestamp); return '日期格式异常'; } const formattedDate = `${year}-${month}-${day}`; return `日期:${formattedDate}<br/>实际身高:${height.toFixed(1)}cm`; } }, legend: { data: ['标准身高', '实际身高'], top: 60, right: 20, itemGap: 20 }, grid: { top: 100 }, xAxis: { type: 'time', name: '日期', min: new Date(birthday.getFullYear(), birthday.getMonth(), birthday.getDate()).getTime(), max: new Date(birthday.getFullYear() + 17, birthday.getMonth(), birthday.getDate()).getTime(), axisLabel: { formatter: function(value) { return echarts.format.formatTime('yyyy-MM', value); } }, axisPointer: { label: { formatter: function(params) { const date = new Date(params.value); return echarts.format.formatTime('yyyy-MM', params.value); } } } }, dataZoom: [ { type: 'slider', show: true, xAxisIndex: 0, start: 0, end: 100, minSpan: 10, maxSpan: 100, filterMode: 'none' }, { type: 'inside', xAxisIndex: 0, zoomLock: false, zoomOnMouseWheel: 'shift', moveOnMouseMove: true, moveOnMouseWheel: true } ], yAxis: { type: 'value', name: '身高 (cm)', min: 50, max: 175 }, series: [ { name: '标准身高', data: standardSeriesData, type: 'line', smooth: true, encode: { x: 'value[0]', y: 'value[1]', tooltip: ['age', 'height'] }, itemStyle: { color: '#ccc' }, lineStyle: { type: 'dashed' }, areaStyle: { color: 'rgba(200, 200, 200, 0.1)' } }, { name: '实际身高', data: [], type: 'line', smooth: true, itemStyle: { color: '#1890ff' }, lineStyle: { width: 2 }, symbolSize: 8, encode: { x: 0, y: 1 }, dimensions: [ { name: 'timestamp', type: 'time' }, // 明确时间类型 { name: 'height', type: 'number' } // 明确数值类型 ] } ] } // 获取数据库信息并格式化数据,这里av是从数据库获取的数据 await getAVDataByBlockId(avBlockId, (av) => { // 动态生成实际测量数据 // 加强数据验证 actualMeasurements = av.keyValues .find(kv => kv.key.name === '日期').values .map((dateValue, index) => { const heightValue = av.keyValues .find(kv => kv.key.name === '身高').values[index].number.content; // 记录原始数据格式 console.log('原始数据 - 日期:', dateValue.date.content, '类型:', typeof dateValue.date.content, '身高:', heightValue, '类型:', typeof heightValue); // 处理数字类型的时间戳 const dateValueType = typeof dateValue.date.content; let finalDate; if (dateValueType === 'number') { finalDate = new Date(dateValue.date.content).toISOString().split('T')[0]; } else if (dateValueType === 'string') { // 尝试解析字符串日期 const parsedDate = new Date(dateValue.date.content); finalDate = isNaN(parsedDate) ? dateValue.date.content : parsedDate.toISOString().split('T')[0]; } else { console.error('未知的日期类型:', dateValueType); finalDate = '无效日期'; } return { date: finalDate, height: heightValue }; }); // 转换实际测量数据为时间戳格式(带验证) const actualSeriesData = actualMeasurements .map(({date, height}) => { // 解析日期 const dateObj = new Date(date); if (isNaN(dateObj.getTime())) { console.error('无效日期格式:', date); return null; } // 转换身高为数字 const heightNum = Number(height); if (isNaN(heightNum)) { console.error('无效身高值:', height); return null; } return [dateObj.getTime(), heightNum]; }) .filter(item => item !== null); // 过滤无效数据 // 更新图表数据和标题 option.series[1].data = actualSeriesData; option.title.text = av.name; }); ////////////////////////////////// 以下代码不涉及数据配置,一般不需要改动 //////////////////////////////////// // 监听av变化,当数据库块被修改时,重新获取数据 if(autoFreshDelay > 0 && !window['__chat_observe__' + avBlockId]) { window['__chat_observe__' + avBlockId] = observeDOMChanges(document.querySelector('.layout__center div[data-node-id="'+avBlockId+'"]'), ()=>{ freshChart(chartBlockId); }, autoFreshDelay, {attributes: false}); } // 输出运行状态,方便调试 console.log('render chart start'); // 获取数据库信息并格式化数据 async function getAVDataByBlockId(blockId, callback) { // 获取块信息 const block = await fetchSyncPost('/api/query/sql', {"stmt": `SELECT * FROM blocks WHERE id = '${blockId}'`}) const markdown = block.data[0]?.markdown; // 获取数据库信息 if(markdown){ // 获取数据库文件地址 const avId = getDataAvIdFromHtml(markdown); // 通过sy文件获取表格数据,按列排列,这里更合适 const av = await fetchSyncPost('/api/file/getFile', {"path":`/data/storage/av/${avId}.json`}); // 通过renderAttributeView获取表格数据,按行排列 //const av = await fetchSyncPost('/api/av/renderAttributeView', {"id":avId,"viewID":"","query":""}); // 格式化数据选项 if(av){ if(typeof callback === 'function') callback(av); } else { option = "未找到av-id=" + avId + "的数据库文件"; } } else { option = "未找到id=" + avBlockId + "的数据库块"; } } // 请求api async function fetchSyncPost (url, data) { const init = { method: "POST", }; if (data) { if (data instanceof FormData) { init.body = data; } else { init.body = JSON.stringify(data); } } const res = await fetch(url, init); const res2 = await res.json(); return res2; } // 获取avid function getDataAvIdFromHtml(htmlString) { // 使用正则表达式匹配data-av-id的值 const match = htmlString.match(/data-av-id="([^"]+)"/); if (match && match[1]) { return match[1]; // 返回匹配的值 } return ""; // 如果没有找到匹配项,则返回空 } // 刷新图表 async function freshChart(chartBlockId) { const ZWSP = "\u200b"; const looseJsonParse = (text) => { return Function(`"use strict";return (${text})`)(); }; const e = document.querySelector('.layout__center div[data-subtype="echarts"][data-node-id="'+chartBlockId+'"]') let width = undefined; if (e.firstElementChild.clientWidth === 0) { const tabElement = hasClosestByClassName(e, "layout-tab-container", true); if (tabElement) { Array.from(tabElement.children).find(item => { if (item.classList.contains("protyle") && !item.classList.contains("fn__none")) { width = item.querySelector(".protyle-wysiwyg").firstElementChild.clientWidth; return true; } }); } } const wysiswgElement = hasClosestByClassName(e, "protyle-wysiwyg", true); if (!e.firstElementChild.classList.contains("protyle-icons")) { e.insertAdjacentHTML("afterbegin", genIconHTML(wysiswgElement)); } const renderElement = e.firstElementChild.nextElementSibling; try { renderElement.style.height = e.style.height; const option = await looseJsonParse(Lute.UnEscapeHTMLStr(e.getAttribute("data-content"))); window.echarts.init(renderElement, window.siyuan.config.appearance.mode === 1 ? "dark" : undefined, {width}).setOption(option); e.setAttribute("data-render", "true"); renderElement.classList.remove("ft__error"); if (!renderElement.textContent.endsWith(ZWSP)) { renderElement.firstElementChild.insertAdjacentText("beforeend", ZWSP); } } catch (error) { window.echarts.dispose(renderElement); renderElement.classList.add("ft__error"); renderElement.innerHTML = `echarts render error: <br>${error}`; } } function hasClosestByClassName(element, className, top = false) { if (!element) { return false; } if (element.nodeType === 3) { element = element.parentElement; } let e = element; let isClosest = false; while (e && !isClosest && (top ? e.tagName !== "BODY" : !e.classList.contains("protyle-wysiwyg"))) { if (e.classList?.contains(className)) { isClosest = true; } else { e = e.parentElement; } } return isClosest && e; } function genIconHTML(element) { let enable = true; if (element) { const readonly = element.getAttribute("contenteditable"); if (typeof readonly === "string") { enable = element.getAttribute("contenteditable") === "true"; } else { return '<div class="protyle-icons"></div>'; } } return `<div class="protyle-icons"> <span aria-label="${window.siyuan.languages.edit}" class="b3-tooltips__nw b3-tooltips protyle-icon protyle-icon--first protyle-action__edit${enable ? "" : " fn__none"}"><svg><use xlink:href="#iconEdit"></use></svg></span> <span aria-label="${window.siyuan.languages.more}" class="b3-tooltips__nw b3-tooltips protyle-icon protyle-action__menu protyle-icon--last${enable ? "" : " protyle-icon--first"}"><svg><use xlink:href="#iconMore"></use></svg></span> </div>`; } // 监听dom变化 let observeTimer = null; function observeDOMChanges(targetNode, callback, debounceTime = 1000, options = {}) { // 默认配置 const defaultOptions = { attributes: true, childList: true, subtree: true, }; // 合并默认配置与传入的配置 const config = Object.assign({}, defaultOptions, options); // 创建一个观察器实例 const observer = new MutationObserver((mutationsList) => { // 使用防抖函数确保单位时间内最多只调用一次回调 if(observeTimer) { clearTimeout(observeTimer); } observeTimer = setTimeout(() => { // 处理变化 callback(mutationsList); }, debounceTime); }); // 开始观察目标节点 observer.observe(targetNode, config); // 返回一个函数,以便在不需要时能够停止观察 return () => { observer.disconnect(); }; } return option; })()
  • Chart
    5 引用 • 6 回帖 • 1 关注
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    24760 引用 • 101771 回帖
1 操作
jsjyyzhc 在 2025-03-03 14:17:55 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...