利用 数据库 和 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 关注
  • 思源笔记

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

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

    24777 引用 • 101887 回帖 • 1 关注
1 操作
jsjyyzhc 在 2025-03-03 14:17:55 更新了该帖

相关帖子

欢迎来到这里!

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

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