基于 chart 图表进行数据库条目信息可视化统计的分享

参考文章《利用 数据库 和 Chart(图表) 做小朋友身高管理》,实现了数据库的进一步可视化展示

Chart 代码

weekStatistics.js

功能:根据日期列,统计本周周一到周日各天的用时情况

(async () => {

    // 关联的数据库块id,这里的id根据需要改成自己的
    const avBlockId = '20250113200532-xns3mip';

    const stats_column = '开始时间';    // 用于统计的数据库列名称
    const chartTitle = ''      // 如果为空,则自动取为数据库标题
    const labelFont = 10


    // 关联的图表块id,这里的id根据需要改成自己的
    const chartBlockId = '20250330181059-28x0usn';

    // 自动刷新延迟,单位是毫秒,默认是1秒,0则不自动刷新
    const autoFreshDelay = 1000;

    // 实际测量数据(将在获取数据库后初始化)
    let option = {
        title: {
            text: chartTitle,
            left: 'center',
            // top: 20,
            textStyle: {
                fontSize: 18
            },
        },
        grid: {
            top: 50,     // 顶部间距
        },
        xAxis: {
            type: 'category',  // 改为分类轴
        },
        yAxis: {
            name: '累计时长 (小时)',
        },
        series: {}

    }

    // 获取数据库信息并格式化数据,这里av是从数据库获取的数据
    await getAVDataByBlockId(avBlockId, (av) => {
        try {
            // 1. 获取数据列
            const xColumn = av.keyValues?.find(kv => kv.key?.name === stats_column);
            if (!xColumn?.values) {
                throw new Error(`数据列不完整: ${stats_column}`);
            }

            // 2. 持续时间分析
            console.log('==== 持续时间分析 ====');
            const durationEntries = [];
            const durationHoursByIndex = {};

            xColumn.values.forEach((dateValue, index) => {
                try {
                    if (dateValue?.date?.content2 && dateValue.date.content2 !== '0') {
                        const startDate = new Date(dateValue.date.content);
                        const endDate = new Date(dateValue.date.content2);

                        // 有效性校验
                        if (isNaN(startDate) || isNaN(endDate)) {
                            console.warn(`[行${index}] 无效日期格式`);
                            return;
                        }

                        // 计算持续时间
                        const durationMs = endDate - startDate;
                        const durationHours = (durationMs / 3600000).toFixed(2);
                        durationHoursByIndex[index] = parseFloat(durationHours);

                        // 记录条目
                        durationEntries.push({
                            index: index + 1,
                            start: dateValue.date.content,
                            end: dateValue.date.content2,
                            hours: durationHours
                        });
                    }
                } catch (e) {
                    console.error(`[行${index}] 数据处理失败:`, e);
                }
            });

            // 3. 控制台输出分析结果
            if (durationEntries.length > 0) {
                console.groupCollapsed(`发现 ${durationEntries.length} 个时段`);
                durationEntries.forEach(entry => {
                    console.log(`行${entry.index}: ${entry.hours}小时`);
                });
                console.groupEnd();
            } else {
                console.log('未发现有效时段');
            }

            // 4. 构建完整周数据结构
            const weekdaysCN = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
            const weeklyData = weekdaysCN.map(weekday => ({
                weekday,
                totalHours: 0,
                order: weekdaysCN.indexOf(weekday) // 排序依据
            }));

            // 5. 日期统计
            const dataStatistic = xColumn.values
                .map((dateValue, index) => {
                    // 5.1 仅处理有效时间段
                    if (!(dateValue?.date?.content2 && dateValue.date.content2 !== '0')) {
                        return null;
                    }

                    // 5.2 获取持续时间
                    const duration = durationHoursByIndex[index];
                    if (typeof duration === 'undefined') return null;

                    // 5.3 解析日期
                    try {
                        const date = new Date(dateValue.date.content);
                        if (isNaN(date)) return null;

                        return {
                            date,
                            weekday: getChineseWeekday(date),
                            duration
                        };
                    } catch (e) {
                        console.warn(`[行${index}] 日期解析失败`);
                        return null;
                    }
                })
                .filter(Boolean);

            // 6. 填充周数据
            dataStatistic.forEach(item => {
                if (!isDateInCurrentWeek(item.date)) return;

                const targetDay = weeklyData.find(d => d.weekday === item.weekday);
                if (targetDay) {
                    targetDay.totalHours += item.duration;
                }
            });

            // 7. 排序并生成图表数据
            const sortedData = weeklyData
                .sort((a, b) => a.order - b.order)
                .map(item => ({
                    ...item,
                    totalHours: Number(item.totalHours.toFixed(2))
                }));

            // 计算持续时间范围
            let maxDuration = 0;
            let minDuration = Infinity;
            dataStatistic.forEach(item => {
                if (item.duration > maxDuration) maxDuration = item.duration;
                if (item.duration < minDuration) minDuration = item.duration;
            });

            // 根据持续时间范围,修改颜色生成函数
            function getColorByDuration(weekday, duration) {
                const colorConfig = {
                    // 工作日:白(100%) → 深蓝(80%)
                    'default': { h: 210, s: 100, range: [100, 80] },
                    // 周末:白(100%) → 深橙(80%)
                    'weekend': { h: 30, s: 100, range: [100, 80] }
                };

                const isWeekend = ['周六', '周日'].includes(weekday);
                const config = isWeekend ? colorConfig.weekend : colorConfig.default;

                const ratio = duration / (maxDuration || 1); // 持续时间比例
                const lightness = config.range[0] - ratio * (config.range[0] - config.range[1]);

                // 确保亮度不低于20%(保持可读性)
                return `hsl(${config.h}, ${config.s}%, ${Math.max(20, lightness)}%)`;
            }


            // 8. 更新图表配置
            option.xAxis.data = sortedData.map(d => d.weekday);
            option.series = {
                name: '持续时间',
                type: 'bar',
                data: sortedData.map(d => ({
                    value: d.totalHours,
                    // 携带元数据用于颜色生成
                    meta: {
                        weekday: d.weekday,
                        duration: d.totalHours
                    }
                })),
                label: {
                    show: true,
                    fontSize: labelFont,
                    formatter: function (params) {
                        // 当值大于0时显示标签
                        return params.value > 0 ? `${params.value} 小时` : '';
                    },
                    color: '#333'
                },
                itemStyle: {
                    color: function (params) {
                        const data = params.data;
                        return getColorByDuration(data.meta.weekday, data.meta.duration);
                    }
                }
            };

            option.title.text = `${chartTitle || av.name} (本周统计)`;

            // 9. 空数据处理
            if (sortedData.every(d => d.totalHours === 0)) {
                option.graphic = {
                    type: 'text',
                    left: 'center',
                    top: 'middle',
                    style: {
                        text: '本周无持续时间记录',
                        fill: '#999',
                        fontSize: 14
                    }
                };
            }

        } catch (e) {
            console.error('数据处理失败:', e);
            option.title = {
                text: '数据加载失败',
                subtext: e.message,
                subtextStyle: { color: '#ff4d4f' }
            };
            option.series.data = [];
        }
    });

    ////////////////////////////////// 以下代码不涉及数据配置,一般不需要改动 ////////////////////////////////////
    // 监听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');
    // 星期转换函数
    function getChineseWeekday(date) {
        const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
        const dayIndex = date.getDay(); // 0是周日,6是周六
        return `周${weekdays[dayIndex]}`; // 返回示例:周一
    }
    // 新增判断函数
    function isWeekend(weekday) {
        const weekendKeywords = ['周六', '周日'];
        return weekendKeywords.some(kw => weekday.includes(kw));
    }

    // 准确判断日期是否在当前周的函数
    function isDateInCurrentWeek(date) {
        if (!(date instanceof Date)) return false;

        // 获取当前时间(确保与被比较日期相同时区)
        const now = new Date();

        // 计算本周一的00:00:00
        const monday = new Date(now);
        monday.setDate(now.getDate() - now.getDay() + (now.getDay() === 0 ? -6 : 1)); // 处理周日的情况
        monday.setHours(0, 0, 0, 0);

        // 计算下周一00:00:00
        const nextMonday = new Date(monday);
        nextMonday.setDate(monday.getDate() + 7);

        // 判断日期是否在 [本周一, 下周一) 区间内
        return date >= monday && date < nextMonday;
    }

    // 获取数据库信息并格式化数据
    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;
})()

categoryStatistics.js

功能:根据所选分类列,统计一个月之内各类别的用时情况

(async () => {

    // 关联的数据库块id,这里的id根据需要改成自己的
    const avBlockId = '20250113200532-xns3mip';



      
    // 改为你的分类列名称
    const categoryColumn = '分类'; 
    // 改为你的日期列
    const dateColumn = '开始时间';  
     // 分类的颜色1,会循环显示柔和的粉红、浅蓝、薄荷绿、深灰
    const legendColors = ['#ff6666', '#6699ff', '#66cc99', '#999999'];
    // 分类的颜色2,会循环显示柔和的粉红、浅蓝、薄荷绿、深灰。可以任选一个,也可以自行定义
    // const legendColors = ['#E3C4B5', '#B7C9D6', '#D9CAB3', '#BCB8B1']; 

    const labelFont = 10;
    const chartTitle = '月度项目统计';


    const is_customMonth = null; // 默认显示当前月
    // 可修改为:
    // const is_customMonth = null; // 默认显示当前月
    // const is_customMonth = 202507; // 显示2025年7月
    // const is_customMonth = "202412"; // 支持字符串格式

    // 关联的图表块id,这里的id根据需要改成自己的
    const chartBlockId = '20250330201447-w1fm4qr';

    // 自动刷新延迟,单位是毫秒,默认是1秒,0则不自动刷新
    const autoFreshDelay = 1000;
    // chart配置
    let option = {
        title: {
            text: chartTitle,
            left: 'center',
            // top: 20,
            textStyle: {
                fontSize: 18
            },
        },
        grid: {
            top: 50,     // 顶部间距
        },
        xAxis: {
            type: 'category',  // 改为分类轴
        },
        yAxis: {
            name: '累计时长 (小时)',
        },
        series: {}

    }

    // 获取数据库信息并格式化数据,这里av是从数据库获取的数据
    // 修改后的核心处理函数
    await getAVDataByBlockId(avBlockId, (av) => {
        try {
            // 1. 获取列对象
            const dateCol = av.keyValues?.find(kv => kv.key?.name === dateColumn);
            const categoryCol = av.keyValues?.find(kv => kv.key?.name === categoryColumn);

            // 2. 创建blockID映射表
            const dateMap = new Map(dateCol?.values?.map(v => [v.blockID, v.date]) || []);
            const categoryMap = new Map(categoryCol?.values?.map(v => [v.blockID, v.mSelect]) || []);

            // 3. 获取有效blockID(同时存在于两列)
            const validBlockIDs = [...new Set([
                ...Array.from(dateMap.keys()),
                ...Array.from(categoryMap.keys())
            ])].filter(blockID => dateMap.has(blockID) && categoryMap.has(blockID));

            // 4. 初始化统计Map
            const durationMap = new Map();

            // 新增代码:日期过滤逻辑 -------------------------------------------------
            let targetYear, targetMonth;
            if (is_customMonth) {
                // 解析自定义年月
                const dateStr = String(is_customMonth).padStart(6, '0');
                const yearPart = dateStr.slice(0, 4);
                const monthPart = dateStr.slice(4);

                targetYear = parseInt(yearPart);
                targetMonth = parseInt(monthPart) - 1; // 转换为0基月份

                // 有效性校验
                if (isNaN(targetYear) || isNaN(targetMonth) || targetMonth < 0 || targetMonth > 11) {
                    console.warn(`无效的月份参数 ${is_customMonth},已切换至当前月`);
                    const now = new Date();
                    targetYear = now.getFullYear();
                    targetMonth = now.getMonth();
                }
            } else {
                // 使用当前年月
                const now = new Date();
                targetYear = now.getFullYear();
                targetMonth = now.getMonth();
            }
            // ---------------------------------------------------------------------

            // 5. 遍历有效行
            validBlockIDs.forEach(blockID => {
                try {
                    const dateValue = dateMap.get(blockID);
                    const categories = categoryMap.get(blockID)?.map(s => s.content) || [];

                    // 5.1 过滤无效时间段
                    if (!dateValue?.content2 || dateValue.content2 === '0') return;

                    // 5.2 解析时间
                    const startDate = new Date(dateValue.content);
                    const endDate = new Date(dateValue.content2);
                    if (isNaN(startDate) || isNaN(endDate)) return;

                    // 修改点:日期过滤逻辑 ---------------------------------------------
                    // 原代码:if (startDate.getMonth() !== currentMonth) return;
                    if (
                        startDate.getFullYear() !== targetYear ||
                        startDate.getMonth() !== targetMonth
                    ) return;
                    // -----------------------------------------------------------------

                    // 5.4 计算持续时间
                    const durationHours = (endDate - startDate) / 3600000;

                    // 5.5 处理分类数据
                    const validCategories = categories.length > 0 ? categories : ['未分类'];

                    // 5.6 累计统计
                    validCategories.forEach(cat => {
                        durationMap.set(cat, (durationMap.get(cat) || 0) + durationHours);
                    });

                } catch (e) {
                    console.error(`[行${blockID}] 处理失败:`, e);
                }
            });

            // 生成图表数据
            const chartData = Array.from(durationMap.entries())
                .map(([category, total]) => ({
                    category,
                    total: Number(total.toFixed(2))
                }))
                .sort((a, b) => b.total - a.total);
            // 计算总时长
            let totalHours = chartData.reduce((sum, d) => sum + d.total, 0);
            totalHours = Number(totalHours.toFixed(2));
            let monthDisplay;
            if (is_customMonth) {
                const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
                monthDisplay = `${targetYear}年${monthNames[targetMonth]}`;
            } else {
                monthDisplay = new Date().toLocaleDateString('zh-CN', {
                    year: 'numeric', month: 'long'
                }).replace('年', '年');
            }

            // +++ 修改标题配置 +++
            option.title = {
                text: `${chartTitle} (${monthDisplay})`,
                subtext: `总时长:${totalHours} 小时`,
                left: 'center',
                textStyle: {
                    fontSize: 18
                },
                subtextStyle: {
                    fontSize: 14,
                    color: '#666',
                    padding: [5, 0]
                }
            };
            // ---------------------------------------------------------------------

            // 更新图表系列数据
            option.xAxis.data = chartData.map(d => d.category);
            option.series = {
                type: 'bar',
                data: chartData.map(d => d.total),
                itemStyle: {
                    color: function (params) {
                        const colors = legendColors; 
                        return colors[params.dataIndex % colors.length];
                    }
                },
                label: {
                    show: true,
                    fontSize: labelFont,
                    formatter: '{@[1]}小时',
                    color: '#333'
                }
            };

            // 空数据处理
            if (chartData.length === 0) {
                option.graphic = {
                    type: 'text',
                    left: 'center',
                    top: 'middle',
                    style: {
                        text: is_customMonth ?
                            `${targetYear}年${targetMonth + 1}月无数据` :
                            '本月无有效数据',
                        fill: '#999',
                        fontSize: 14
                    }
                };
            }

        } catch (e) {
            console.error('数据处理失败:', e);
            option.title = {
                text: '数据加载失败',
                subtext: e.message,
                subtextStyle: { color: '#ff4d4f' }
            };
        }
    });

    ////////////////////////////////// 以下代码不涉及数据配置,一般不需要改动 ////////////////////////////////////
    // 监听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;
})()

视频功能演示

补充说明

2025 年 3 月 31 日

当第一次打开思源笔记后,图表块可能会报错 echarts render error: TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'.

这是由于刚打开思源笔记软件,用于图表块渲染的目标数据库还未加载。解决方法也很简单,手动把目标数据库页面打开一下(后续无需常开该页面),之后就能正常显示了。

  • 思源笔记

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

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

    25118 引用 • 103562 回帖 • 1 关注
1 操作
ABin666 在 2025-03-31 10:42:18 更新了该帖

相关帖子

欢迎来到这里!

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

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

    牛的,我的插件又焕发出第二春了trollface

    1 回复
  • 真牛,这个应该可以被官方封装一下当做是默认的

    嘿嘿,希望有机会
    ABin666
  • ABin666 1 评论

    哇,大大你好啊。很高兴这个程序对你有帮助。
    也是非常感谢你开发的 STtools 插件啊。我正是在使用了你开发的插件后,才有了这个图表可视化的灵感!

    也十分感谢你的分享 😊
    stevehfut