这篇文章是 Obsidian Dataview 系列 系列 15 篇文章中的第 14 篇

[!quote] 背景
主要梳理了 Dataview 示例库 Dataview Example Valut 中的一些案例

三、高级篇:Dataview 高级技巧与探索

高级篇的内容主要是一些不常用,但是实用,需要更多的代码的内容,或者需要结合第三方插件的内容。

1. 表格列求和

在电子表格中,我们可以对列数据进行运算,如求和、未平均值等。下面我们来看一下如何在 Dataview 查询的结果中实现对列数据的求和。

const query = `TABLE praying, training, situps, steps
FROM "10 Example Data/dailys"
WHERE file.day.month = 2`

const nameOfTotalRow = "Sums";

let DQL = await dv.tryQuery(query);
const sums = [nameOfTotalRow];

// 如果在 DQL 查询语句中添加了 `WITHOUT ID`,这里就需要改成从 `0` 开始遍历
for (let i = 1; i < DQL.headers.length; i++) {
    let sum = 0;
    const dataType = getDatatypeOfColumn(i, DQL.values)

    // 只有数字和持续时间的数据类型才会被计算
    if (!["number", "duration"].includes(dataType)) {
        sums.push("")
        continue;
    }

    // 计算每一列的总和
    for (let k = 0; k < DQL.values.length; k++) {
        // 行 `k`, 列 `i` 的值
        let currentValue = DQL.values[k][i];
        if (currentValue) sum += currentValue 
    }
    if (!sum) sum = ""
    sums.push(dataType === "duration" ? dv.luxon.Duration.fromMillis(sum) : sum);
}

function getDatatypeOfColumn(columnNo, values) {
    let i = 0;
    let datatype;
    while (i < DQL.values[0].length && (!datatype || datatype === "null")) {
        datatype = dv.func.typeof(DQL.values[i][columnNo])
        i++;
    }
    return datatype;
}

// 添加分隔线
let hrArray = Array(DQL.headers.length).fill('<hr style="padding:0; margin:0 -10px;">');
DQL.values.push(hrArray)
DQL.values.push(sums)

dv.table(DQL.headers, DQL.values)

结果:
image.png|1000

2. 在文档中搜索文字

要在文档中搜索指定的单词,我们首先需要将文件读到内容中,可使用 dv.io.load() 方法,然后再通过正则去匹配文本。

const word = "but"

const regex = new RegExp("(\\S+\\s?){0,2}(\\b"+word+"\\b)(\\s\\S+){0,2}", "gi")
const pages = await Promise.all(
    dv.pages('"30 Dataview Resources"')
    .map(async (page) => {
        const content = await dv.io.load(page.file.path);
        const matches = content.match(regex);
        return {
            link: page.file.link,
            count: ( matches || []).length,
            matches
        };
    })
)

dv.table(
        ["Note", "Count", `Matches for "${word}"`],
            pages
            .filter(p => p.count)
            .sort((a, b) => b.count - a.count)
            .map(p => [p.link, p.count, p.matches])  
    );

结果:
image.png|1000
上面的正则表达式中 \b 用于匹配单词边界(英文句子单词之间以空格分隔),然后最左边的 (\\S+\\s?){0,2} 和右边的 (\\s\\S+){0,2} 用于匹配目标单词前后的两个相邻单词。基中 + 符表示匹配 1 次或多次,? 表示匹配 0 次或多次,\s 表示匹配一个空白字符(包括空格、制表符、换页符和换行符),\S 表示匹配一个非空白符。dv.io.load() 方法用于将文件加载到内存中。

3. 使用选项卡切换数据

在查询数据时,有的数据不同的状态会有不同的结果,我们可以按状态来进行条件显示。将状态作为选项卡,而其关联的内容作为选项卡内容。

const createButton = name => {
    const btn = dv.el('button', name)
    btn.addEventListener('click', () => {
        event.preventDefault()
        removeTable()
        renderTable(name)
    })

    return btn
}

const buttons = ['Watching', 'Going to watch', 'Watched all', 'Stopped watching']

const renderTable = name => {
    const pages = dv.pages('"10 Example Data/shows"').where(p => p.status === name)
    dv.header(2, name)
    dv.table(
        ['Title', 'Rating', 'Runtime', 'Seasons', 'Episodes'],
        pages.map(p => {
            let watchedEp = 0
            const totalEp = p.episodes

            p.file.tasks.forEach(t => {
                if (t.checked) {
                    watchedEp++
                }
            })

            return [p.file.link, p.rating, p.runtime, p.seasons, `${watchedEp}/${totalEp}`]
        })
    )
}

const removeTable = () => {
    this.container.lastChild.remove()
    this.container.lastChild.remove()
}

buttons.forEach(button => createButton(button))

renderTable('Watching')

结果:
image.png|1000
上面的代码中,我们使用 dv.el() 来创建了按钮并添加了事件处理逻辑。在选项卡被选中时,根据选项卡名去过滤查询结果,并将上一次渲染的 HTML 节点移除掉。

进一步,我们还可以实现同一份数据结果以不同的方式渲染:

const views = ['Table', 'List', 'Tasks']

const changeView = viewName => {
    removeView()

    if (viewName === 'Table') {
        dv.header(2, 'Some table')
        dv.table(['File', 'Day'], dv.pages('"10 Example Data/dailys"').limit(7).map(p => [p.file.link, p.day]))
    }

    if (viewName == 'List') {
        dv.list(dv.pages('"10 Example Data/dailys"').limit(7).file.name)
    }

    if (viewName == 'Tasks') {
        dv.taskList(dv.page("10 Example Data/projects/project_2").file.tasks)
    }
}

const createButtons = () => {
    const buttonContainer = dv.el('div', '', {cls: 'tabButtons'})
    views.forEach(view => {
        const button = dv.el('button', view)

        button.addEventListener('click', event => {
            event.preventDefault()
            changeView(view)
        })

        buttonContainer.append(button)
    })
}

const removeView = () => {
    Array.from(this.container.children).forEach(el => {
        if (!el.classList.contains('tabButtons')) {
            el.remove()
        }
    })
}

createButtons()

4. 使用不同的表情符来显示时间缀

这个案例我们查询任务计划数据,来获取未完成的任务距离现在过去了多长时间,并对其按时间长度自定义不同的表情符来显示得分。

  • 如果月数超过6个月,则添加 “🥳” 表情符号。
  • 如果剩余的月数(在超过6个月后)超过3个月,则添加 “🎉” 表情符号。
  • 如果剩余的月数(在超过9个月后)仍然有剩余,则添加 “🎁” 表情符号。
const projects = dv.pages('"10 Example Data/projects"')
    .where(p => p.status !== undefined && p.status !== "finished")
    .mutate(p => {
        p.age = p.started && p.started instanceof dv.luxon.DateTime ? dv.luxon.Duration.fromMillis(Date.now() - p.started.toMillis()) : null
        p.emojiAgeScore = getEmojiScore(p)
    })

dv.table(["Score", "Project", "Started", "Age"], projects.map(p => [p.emojiAgeScore, p.file.link, p.started, p.age ? p.age.toFormat("y'年' M'个月' w'周'") : 'N/A']))

function getEmojiScore(p) {
    const age = p.age.shiftTo('months').toObject()
    let score = ""

    score += addEmojis("🥳", age.months / 6)
    score += addEmojis("🎉", (age.months % 6) / 3)
    score += addEmojis("🎁", age.months % 6 % 3)

    return score
}

function addEmojis(emoji, max) {
    let emojis = ""
    for (let i = 0; i < Math.floor(max); i++) emojis += emoji
    return emojis
}

结果:
image.png|1000
代码中 shiftTo('months').toObject() 函数用于将时间缀转换成类似:xx个月 的形式。

关于 Luxon 的使用可以阅读系列中的 Luxon 章节。

5. 将数据渲染成日历

下面这个案例可以好好研究一下如何运用 Luxon 填充每天的数据和构造 HTML 结构。

const values = dv.pages('"10 Example Data/dailys"').where(p => p.wellbeing?.mood)
const year = 2022
const color = "green"
const emptyColor = "#e4e4e4"
const dt = dv.luxon.DateTime

// 创建日历数据
let date = dt.utc(year)
const calendar = []
S
for (let i = 1; i <= 12; i++) {
    calendar[i] = []
}

// 填充日历数据
while (date.year === year) {
    calendar[date.month].push(getDayEl(date, determineColor(date)))

    date = addOneDay(date);
}

// 渲染日历
calendar.forEach((month, i) => {
    const monthEl = `<span style="display:inline-block;width:30px;font-size:small">${dt.utc(year, i).monthShort}</span>`
    dv.el("div", monthEl + month.reduce((acc, cur) => `${acc} ${cur}`, ""))
})

function addOneDay(date) {
    return dt.fromMillis(date + dv.duration("1d"))
}

function getDayEl(date, color) {
    const sizeOfDays = "12px"
    return `<span style="width:${sizeOfDays};height:${sizeOfDays};border-radius:2px;background-color:${color};display:inline-block;font-size:4pt;" title="${date.toFormat('yyyy-MM-dd')}"></span>`
}

function determineColor(date) {
    const page = values.find(p => p.file.day.startOf('day').equals(date.startOf('day')));
    if (!page) return emptyColor;


    let opacity = (page.wellbeing.mood / 4) ;
    return `rgba(177, 200, 51, ${opacity})`;

}

结果:
image.png|1000

进一步阅读:Render a year overview for your data – Dataview Example Vault (s-blu.github.io)

6. 使用 Chart. Js 渲染图表

要在 Obsidian 中渲染图表,我们需要用到 Obsidian-Charts 这个插件。

let chartType = 'bar'; //bar or line

let xAxis = "xAxis: {type:'time', time: {unit: 'day'}}"; // {type:'category'}";
let yAxis = "yAxis: {suggestedMin: 0, ticks: {stepSize: 1}";

let autoLabels = true; // 自动设置标签
var labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; // autoLabels 为 false 时,手动设置标签

var colors = [['#ff6384'],['#36a2eb'],['#ffce56'],['#4bc0c0'],['#9966ff'],['#ff9f40']];

let sDQL = '\
    TABLE WITHOUT ID \
        file.name as "Date", \
        wellbeing.mood as "Mood", \
        wellbeing.health as "Health", \
        wellbeing.pain as "Pain" \
    FROM "10 Example Data/dailys" \
    WHERE date(file.name).year = 2022 \
    SORT file.name'

let DQL = await dv.tryQuery(sDQL);
var allRows = DQL.values;
var allLabels = allRows.map(r => r[0]);
var allSeries = DQL.headers.slice(1);

if (autoLabels) {
    labels = allLabels;
}

var datasets = [];
for (let i = 0; i < allSeries.length; i++) {    
    let seriesName = allSeries[i];
    let backCol = colors[i%colors.length];
    let bordCol = colors[i%colors.length];
    let bWidth = 1;

    var dataPoints = [];
    if (!autoLabels) {
        dataPoints = labels.map(l => {
            let labelIndex = allLabels.indexOf(l);
            if (labelIndex < 0) { return 0 }
            else { return allRows[labelIndex][i+1] }
        })  
    } else {
        dataPoints = allRows.map(r => r[i+1]);
    }

    let chartDataset = {label: seriesName, 
                        data: dataPoints, 
                        backgroundColor: backCol,
                        borderColor: bordCol, 
                        borderWidth: bWidth};

    datasets.push(chartDataset);
}

// 如果我们使用固定的标签数组,请使xAxis基于类别
xAxis = (autoLabels ? xAxis : "xAxis: {type:'category'}");

// chart.js 选项配置
const chartData = {
    type: chartType,
    data: { 
        labels: labels,
        datasets: datasets
    },
    options: {  
        scales: { xAxis, yAxis }
    }
}
window.renderChart(chartData, this.container);

7. 使用 Heatmap Calendar 插件显示热力圈

这个案例我们来使用插件 Richardsl/heatmap-calendar-obsidian: An Obsidian plugin for displaying data in a calendar similar to the github activity calendar 渲染一个步数的热力图。

const calendarData = {
    year: 2022,
    entries: []
}

for (let page of dv.pages('"10 Example Data/dailys"').filter(p => p.steps)) {
    calendarData.entries.push({
        date: page.file.name,
        intensity: page.steps,
        content: await dv.span(`[](${page.file.name})`) // 用于预览
    })
}
renderHeatmapCalendar(this.container, calendarData)
系列目录<< Obsidian插件Dataview —— 实用案例讲解(中级篇)(十三)Obsidian插件Dataview —— 函数合集(十五) >>