实现功能
- 数据库不定期录入小朋友身高
- 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';
显示效果
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;
})()
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于