mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-21 07:10:50 +08:00
improvement(wrapped): 全局概览改为年度日历热力图
- card_00_global_overview 输出 annualHeatmap(dailyCounts + highlights) - 新增 AnnualCalendarHeatmap:横向滚动网格 + 气泡 tooltip + 高光日文案 - GlobalOverviewChart 从 Radar 重构为 Heatmap;Card00 slide 下微调间距 - MessageCharsChart 复用 msg-bubble 样式,统一气泡外观
This commit is contained in:
@@ -98,7 +98,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<GlobalOverviewChart :data="card.data || {}" />
|
<div :class="variant === 'slide' ? 'w-full -mt-2 sm:-mt-4' : 'w-full'">
|
||||||
|
<GlobalOverviewChart :data="card.data || {}" />
|
||||||
|
</div>
|
||||||
</WrappedCardShell>
|
</WrappedCardShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div v-if="weeks > 0" class="overflow-x-auto" data-wrapped-scroll-x>
|
||||||
|
<div class="w-max mx-auto" :style="{ '--cell': `${cellPx}px` }">
|
||||||
|
<!-- Month labels -->
|
||||||
|
<div
|
||||||
|
class="grid gap-[2px] text-[11px] text-[#00000066] mb-2"
|
||||||
|
:style="{ gridTemplateColumns: `36px repeat(${weeks}, var(--cell))` }"
|
||||||
|
>
|
||||||
|
<div></div>
|
||||||
|
<span
|
||||||
|
v-for="(m, idx) in monthLabels"
|
||||||
|
:key="idx"
|
||||||
|
class="wrapped-number whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ m }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid -->
|
||||||
|
<div
|
||||||
|
class="grid gap-[2px] items-stretch"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns: `36px repeat(${weeks}, var(--cell))`,
|
||||||
|
gridTemplateRows: `repeat(7, var(--cell))`
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(w, wi) in weekdayTicks"
|
||||||
|
:key="wi"
|
||||||
|
class="flex items-center wrapped-body text-[11px] text-[#00000066]"
|
||||||
|
:style="{ gridColumn: '1', gridRow: String(wi + 1) }"
|
||||||
|
>
|
||||||
|
{{ w }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(c, idx) in cells"
|
||||||
|
:key="idx"
|
||||||
|
class="heatmap-cell rounded-[2px] transition-transform duration-150 hover:scale-125 hover:z-10"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: colorFor(c),
|
||||||
|
transformOrigin: originFor(c),
|
||||||
|
gridColumn: String((c.col ?? 0) + 2),
|
||||||
|
gridRow: String((c.row ?? 0) + 1)
|
||||||
|
}"
|
||||||
|
@mouseenter="showTooltip(c, $event)"
|
||||||
|
@mousemove="scheduleTooltipLayout"
|
||||||
|
@mouseleave="hideTooltip"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between text-xs text-[#00000066] w-full">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="wrapped-body">低</span>
|
||||||
|
<div class="flex items-center gap-[2px]">
|
||||||
|
<span
|
||||||
|
v-for="i in 6"
|
||||||
|
:key="i"
|
||||||
|
class="heatmap-legend-cell w-4 h-2 rounded-[2px]"
|
||||||
|
:style="{ backgroundColor: legendColor(i) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="wrapped-body">高</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="maxValue > 0" class="wrapped-number">最大 {{ maxValue }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tooltipOpen && tooltipCell && tooltipCell.ymd"
|
||||||
|
ref="tooltipEl"
|
||||||
|
class="fixed z-[60] pointer-events-none"
|
||||||
|
:style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<div class="wr-heatmap-tooltip">
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<span class="wr-heatmap-tooltip__time wrapped-number">{{ tooltipCell.ymd }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r">
|
||||||
|
<div class="wrapped-body">{{ tooltipPrimaryText }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(line, i) in tooltipHighlightLines" :key="i" class="flex justify-start">
|
||||||
|
<div class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-white text-gray-800 bubble-tail-l">
|
||||||
|
<div class="wrapped-body">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="wr-heatmap-tooltip__arrow"
|
||||||
|
:class="tooltipPlacement === 'bottom' ? 'wr-heatmap-tooltip__arrow--top' : 'wr-heatmap-tooltip__arrow--bottom'"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { heatColor } from '~/utils/wrapped/heatmap'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
year: { type: Number, default: new Date().getFullYear() },
|
||||||
|
// 0-indexed day-of-year array; length should be 365/366
|
||||||
|
dailyCounts: { type: Array, default: () => [] },
|
||||||
|
days: { type: Number, default: 0 },
|
||||||
|
highlights: { type: Array, default: () => [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cell size of each day square (px). Tuned to fit Card00 slide width without truncation.
|
||||||
|
const cellPx = 15
|
||||||
|
|
||||||
|
const MARKER_ORDER = [
|
||||||
|
'sent_chars_max',
|
||||||
|
'received_chars_max',
|
||||||
|
'sent_messages_max',
|
||||||
|
'received_messages_max',
|
||||||
|
'added_friends_max',
|
||||||
|
'sticker_messages_max',
|
||||||
|
'emoji_chars_max'
|
||||||
|
]
|
||||||
|
|
||||||
|
const isLeapYear = (y) => {
|
||||||
|
const n = Number(y)
|
||||||
|
if (!Number.isFinite(n)) return false
|
||||||
|
return n % 4 === 0 && (n % 100 !== 0 || n % 400 === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysInYear = computed(() => {
|
||||||
|
const d = Number(props.days || 0)
|
||||||
|
const arr = Array.isArray(props.dailyCounts) ? props.dailyCounts : []
|
||||||
|
if (d > 0) return d
|
||||||
|
if (arr.length > 0) return arr.length
|
||||||
|
return isLeapYear(props.year) ? 366 : 365
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts = computed(() => {
|
||||||
|
const arr = Array.isArray(props.dailyCounts) ? props.dailyCounts : []
|
||||||
|
const out = []
|
||||||
|
for (let i = 0; i < daysInYear.value; i += 1) out.push(Number(arr[i] || 0))
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlightsMap = computed(() => {
|
||||||
|
const hs = Array.isArray(props.highlights) ? props.highlights : []
|
||||||
|
const map = new Map()
|
||||||
|
for (const raw of hs) {
|
||||||
|
const key = typeof raw?.key === 'string' ? raw.key : ''
|
||||||
|
const doyNum = Number(raw?.doy)
|
||||||
|
if (!key || !Number.isFinite(doyNum)) continue
|
||||||
|
const doy = Math.floor(doyNum)
|
||||||
|
if (doy < 0 || doy >= daysInYear.value) continue
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
key,
|
||||||
|
label: typeof raw?.label === 'string' && raw.label.trim() ? raw.label.trim() : key,
|
||||||
|
valueLabel: typeof raw?.valueLabel === 'string' ? raw.valueLabel : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const arr = map.get(doy) || []
|
||||||
|
arr.push(item)
|
||||||
|
map.set(doy, arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort markers per-day by a stable order to keep UI deterministic.
|
||||||
|
for (const [doy, arr] of map.entries()) {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const ia = MARKER_ORDER.indexOf(a.key)
|
||||||
|
const ib = MARKER_ORDER.indexOf(b.key)
|
||||||
|
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
|
||||||
|
})
|
||||||
|
map.set(doy, arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxValue = computed(() => {
|
||||||
|
let m = 0
|
||||||
|
for (const v of counts.value) {
|
||||||
|
const n = Number(v)
|
||||||
|
if (Number.isFinite(n) && n > m) m = n
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
|
const jan1UtcMs = computed(() => Date.UTC(Number(props.year), 0, 1))
|
||||||
|
const startWeekday = computed(() => {
|
||||||
|
const d = new Date(jan1UtcMs.value)
|
||||||
|
const w = d.getUTCDay() // 0=Sun..6=Sat
|
||||||
|
return (w + 6) % 7 // 0=Mon..6=Sun
|
||||||
|
})
|
||||||
|
|
||||||
|
const weeks = computed(() => Math.ceil((daysInYear.value + startWeekday.value) / 7))
|
||||||
|
|
||||||
|
const weekdayTicks = computed(() => ['周一', '', '周三', '', '周五', '', '周日'])
|
||||||
|
|
||||||
|
const monthLabels = computed(() => {
|
||||||
|
const cols = weeks.value
|
||||||
|
const out = Array.from({ length: cols }, () => '')
|
||||||
|
for (let m = 0; m < 12; m += 1) {
|
||||||
|
const monthStart = Date.UTC(Number(props.year), m, 1)
|
||||||
|
const doy = Math.round((monthStart - jan1UtcMs.value) / 86400000)
|
||||||
|
const col = Math.floor((doy + startWeekday.value) / 7)
|
||||||
|
if (col >= 0 && col < out.length && !out[col]) out[col] = `${m + 1}月`
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const cells = computed(() => {
|
||||||
|
const out = []
|
||||||
|
const cols = weeks.value
|
||||||
|
const leading = startWeekday.value
|
||||||
|
const totalCells = cols * 7
|
||||||
|
for (let i = 0; i < totalCells; i += 1) {
|
||||||
|
const col = Math.floor(i / 7)
|
||||||
|
const row = i % 7
|
||||||
|
const doy = i - leading
|
||||||
|
if (doy < 0 || doy >= daysInYear.value) {
|
||||||
|
out.push({
|
||||||
|
valid: false,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
count: 0,
|
||||||
|
ymd: '',
|
||||||
|
highlights: []
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = new Date(Date.UTC(Number(props.year), 0, 1 + doy))
|
||||||
|
const y = d.getUTCFullYear()
|
||||||
|
const mo = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
const da = String(d.getUTCDate()).padStart(2, '0')
|
||||||
|
const ymd = `${y}-${mo}-${da}`
|
||||||
|
|
||||||
|
const highlights = highlightsMap.value.get(doy) || []
|
||||||
|
const normalizedHighlights = Array.isArray(highlights) ? highlights : []
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
valid: true,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
doy,
|
||||||
|
ymd,
|
||||||
|
count: Number(counts.value[doy] || 0),
|
||||||
|
highlights: normalizedHighlights
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorFor = (cell) => {
|
||||||
|
if (!cell || !cell.valid) return 'transparent'
|
||||||
|
return heatColor(cell.count, maxValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipOpen = ref(false)
|
||||||
|
const tooltipCell = ref(null)
|
||||||
|
const tooltipX = ref(0)
|
||||||
|
const tooltipY = ref(0)
|
||||||
|
const tooltipPlacement = ref('top') // 'top' | 'bottom'
|
||||||
|
const tooltipEl = ref(null)
|
||||||
|
const tooltipAnchorEl = ref(null)
|
||||||
|
let tooltipRaf = 0
|
||||||
|
|
||||||
|
const tooltipPrimaryText = computed(() => {
|
||||||
|
const c = tooltipCell.value
|
||||||
|
if (!c || !c.valid) return ''
|
||||||
|
const n = Number(c.count) || 0
|
||||||
|
if (n <= 0) return '这一天没有聊天消息'
|
||||||
|
return `这一天有 ${n} 条聊天消息`
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipHighlightLines = computed(() => {
|
||||||
|
const c = tooltipCell.value
|
||||||
|
if (!c || !c.valid) return []
|
||||||
|
const hs = Array.isArray(c.highlights) ? c.highlights : []
|
||||||
|
const out = []
|
||||||
|
for (const h of hs) {
|
||||||
|
if (!h) continue
|
||||||
|
const label = String(h.label || h.key || '').trim()
|
||||||
|
if (!label) continue
|
||||||
|
const v = String(h.valueLabel || '').trim()
|
||||||
|
out.push(v ? `${label}:${v}` : label)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateTooltipLayout = () => {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const anchor = tooltipAnchorEl.value
|
||||||
|
const tip = tooltipEl.value
|
||||||
|
if (!anchor || !tip) return
|
||||||
|
|
||||||
|
const a = anchor.getBoundingClientRect()
|
||||||
|
const t = tip.getBoundingClientRect()
|
||||||
|
if (!t.width || !t.height) return
|
||||||
|
|
||||||
|
const gap = 10
|
||||||
|
const padding = 10
|
||||||
|
|
||||||
|
let left = a.left + a.width / 2 - t.width / 2
|
||||||
|
left = Math.min(window.innerWidth - padding - t.width, Math.max(padding, left))
|
||||||
|
|
||||||
|
let top = a.top - gap - t.height
|
||||||
|
let placement = 'top'
|
||||||
|
if (top < padding) {
|
||||||
|
top = a.bottom + gap
|
||||||
|
placement = 'bottom'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top + t.height > window.innerHeight - padding) {
|
||||||
|
top = window.innerHeight - padding - t.height
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltipX.value = Math.round(left)
|
||||||
|
tooltipY.value = Math.round(top)
|
||||||
|
tooltipPlacement.value = placement
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleTooltipLayout = () => {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
if (!tooltipOpen.value) return
|
||||||
|
if (tooltipRaf) cancelAnimationFrame(tooltipRaf)
|
||||||
|
tooltipRaf = requestAnimationFrame(() => {
|
||||||
|
tooltipRaf = 0
|
||||||
|
updateTooltipLayout()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTooltip = async (cell, e) => {
|
||||||
|
if (!cell || !cell.valid || !cell.ymd) return
|
||||||
|
tooltipCell.value = cell
|
||||||
|
tooltipAnchorEl.value = e?.currentTarget || null
|
||||||
|
tooltipOpen.value = true
|
||||||
|
await nextTick()
|
||||||
|
updateTooltipLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
tooltipOpen.value = false
|
||||||
|
tooltipCell.value = null
|
||||||
|
tooltipAnchorEl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
window.addEventListener('resize', scheduleTooltipLayout)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
window.removeEventListener('resize', scheduleTooltipLayout)
|
||||||
|
if (tooltipRaf) cancelAnimationFrame(tooltipRaf)
|
||||||
|
tooltipRaf = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const legendColor = (i) => {
|
||||||
|
const m = maxValue.value || 1
|
||||||
|
const t = i / 6
|
||||||
|
return heatColor(Math.max(1, t * m), m)
|
||||||
|
}
|
||||||
|
|
||||||
|
const originFor = (cell) => {
|
||||||
|
if (!cell) return 'center center'
|
||||||
|
const col = Number(cell.col || 0)
|
||||||
|
const row = Number(cell.row || 0)
|
||||||
|
const x = col === 0 ? 'left' : (col === weeks.value - 1 ? 'right' : 'center')
|
||||||
|
const y = row === 0 ? 'top' : (row === 6 ? 'bottom' : 'center')
|
||||||
|
return `${x} ${y}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wr-heatmap-tooltip {
|
||||||
|
@apply relative w-[260px] max-w-[80vw] rounded-2xl border border-[#00000010] bg-[#F5F5F5]/95 backdrop-blur px-3 py-3 shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wr-heatmap-tooltip__time {
|
||||||
|
@apply inline-flex items-center justify-center px-2 py-[2px] rounded-md border border-[#0000000a] bg-white/70 text-[10px] text-[#00000066];
|
||||||
|
}
|
||||||
|
|
||||||
|
.wr-heatmap-tooltip__arrow {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wr-heatmap-tooltip__arrow--bottom {
|
||||||
|
bottom: -8px;
|
||||||
|
border-left: 8px solid transparent;
|
||||||
|
border-right: 8px solid transparent;
|
||||||
|
border-top: 8px solid rgba(245, 245, 245, 0.95);
|
||||||
|
filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wr-heatmap-tooltip__arrow--top {
|
||||||
|
top: -8px;
|
||||||
|
border-left: 8px solid transparent;
|
||||||
|
border-right: 8px solid transparent;
|
||||||
|
border-bottom: 8px solid rgba(245, 245, 245, 0.95);
|
||||||
|
filter: drop-shadow(0 -1px 0 rgba(0, 0, 0, 0.06));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,271 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="overview-card">
|
<AnnualCalendarHeatmap
|
||||||
<div class="flex items-center justify-between gap-4">
|
:year="year"
|
||||||
<div class="wrapped-label text-xs text-[#00000066]">年度聊天画像</div>
|
:daily-counts="annualDailyCounts"
|
||||||
<div class="wrapped-body text-xs text-[#00000066]">Radar</div>
|
:days="daysInYear"
|
||||||
</div>
|
:highlights="annualHighlights"
|
||||||
|
/>
|
||||||
<div class="mt-4 grid gap-6 sm:grid-cols-[280px_1fr] items-center">
|
|
||||||
<div class="w-full max-w-[320px] mx-auto">
|
|
||||||
<svg viewBox="0 0 220 220" class="w-full h-auto select-none">
|
|
||||||
<!-- Grid -->
|
|
||||||
<g>
|
|
||||||
<polygon
|
|
||||||
v-for="i in rings"
|
|
||||||
:key="i"
|
|
||||||
:points="gridPolygonPoints(i / rings)"
|
|
||||||
fill="none"
|
|
||||||
class="overview-grid-line"
|
|
||||||
stroke-width="1"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
v-for="(p, idx) in axisPoints"
|
|
||||||
:key="idx"
|
|
||||||
:x1="cx"
|
|
||||||
:y1="cy"
|
|
||||||
:x2="p.x"
|
|
||||||
:y2="p.y"
|
|
||||||
class="overview-axis-line"
|
|
||||||
stroke-width="1"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Data polygon -->
|
|
||||||
<polygon
|
|
||||||
:points="dataPolygonPoints"
|
|
||||||
class="overview-data-polygon"
|
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Data nodes + tooltips -->
|
|
||||||
<g>
|
|
||||||
<circle
|
|
||||||
v-for="(p, idx) in dataPoints"
|
|
||||||
:key="idx"
|
|
||||||
:cx="p.x"
|
|
||||||
:cy="p.y"
|
|
||||||
r="4"
|
|
||||||
class="overview-data-node"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<title>{{ p.title }}</title>
|
|
||||||
</circle>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Labels -->
|
|
||||||
<g>
|
|
||||||
<text
|
|
||||||
v-for="(l, idx) in labels"
|
|
||||||
:key="idx"
|
|
||||||
:x="l.x"
|
|
||||||
:y="l.y"
|
|
||||||
:text-anchor="l.anchor"
|
|
||||||
dominant-baseline="middle"
|
|
||||||
font-size="11"
|
|
||||||
class="overview-label"
|
|
||||||
>
|
|
||||||
{{ l.label }}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-3">
|
|
||||||
<div
|
|
||||||
v-for="m in metrics"
|
|
||||||
:key="m.key"
|
|
||||||
class="flex items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div class="wrapped-body text-sm text-[#00000099]">{{ m.name }}</div>
|
|
||||||
<div class="flex items-center gap-3 min-w-[160px]">
|
|
||||||
<div class="overview-progress-bg">
|
|
||||||
<div class="overview-progress-fill" :style="{ width: Math.round(m.norm * 100) + '%' }" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'wrapped-number text-sm w-[74px] text-right',
|
|
||||||
m.display === '—' ? 'text-[#00000055]' : 'text-[#07C160] font-semibold'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ m.display }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Note removed per UI requirement (keep layout compact). -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import AnnualCalendarHeatmap from '~/components/wrapped/visualizations/AnnualCalendarHeatmap.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: { type: Object, default: () => ({}) }
|
data: { type: Object, default: () => ({}) }
|
||||||
})
|
})
|
||||||
|
|
||||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
const year = computed(() => {
|
||||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
const v = props.data?.annualHeatmap?.year ?? props.data?.year ?? new Date().getFullYear()
|
||||||
|
const y = Number(v)
|
||||||
const formatFloat = (n, digits = 1) => {
|
return Number.isFinite(y) ? y : new Date().getFullYear()
|
||||||
const v = Number(n)
|
|
||||||
if (!Number.isFinite(v)) return '0'
|
|
||||||
return v.toFixed(digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0))
|
|
||||||
const logNorm = (v, maxLog) => {
|
|
||||||
const n = Number(v) || 0
|
|
||||||
const ml = Number(maxLog) || 1
|
|
||||||
if (n <= 0) return 0
|
|
||||||
return clamp01(Math.log10(1 + n) / ml)
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMessages = computed(() => Number(props.data?.totalMessages || 0))
|
|
||||||
const activeDays = computed(() => Number(props.data?.activeDays || 0))
|
|
||||||
const messagesPerDay = computed(() => Number(props.data?.messagesPerDay || 0))
|
|
||||||
|
|
||||||
const topContactMessages = computed(() => Number(props.data?.topContact?.messages || 0))
|
|
||||||
const topGroupMessages = computed(() => Number(props.data?.topGroup?.messages || 0))
|
|
||||||
|
|
||||||
const topKindPct = computed(() => {
|
|
||||||
const ratio = Number(props.data?.topKind?.ratio || 0)
|
|
||||||
if (!Number.isFinite(ratio) || ratio <= 0) return 0
|
|
||||||
return Math.max(0, Math.min(100, Math.round(ratio * 100)))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const metrics = computed(() => [
|
const daysInYear = computed(() => {
|
||||||
{
|
const d = Number(props.data?.annualHeatmap?.days || 0)
|
||||||
key: 'totalMessages',
|
if (Number.isFinite(d) && d > 0) return d
|
||||||
name: '发送消息',
|
const y = Number(year.value)
|
||||||
label: '发送',
|
const isLeap = y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0)
|
||||||
display: `${formatInt(totalMessages.value)} 条`,
|
return isLeap ? 366 : 365
|
||||||
norm: logNorm(totalMessages.value, 6)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'activeDays',
|
|
||||||
name: '发消息天数',
|
|
||||||
label: '天数',
|
|
||||||
display: `${formatInt(activeDays.value)}/365`,
|
|
||||||
norm: clamp01(activeDays.value / 365)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'messagesPerDay',
|
|
||||||
name: '日均发送',
|
|
||||||
label: '日均',
|
|
||||||
display: `${formatFloat(messagesPerDay.value, 1)} 条`,
|
|
||||||
norm: logNorm(messagesPerDay.value, 3)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'topContactMessages',
|
|
||||||
name: '发得最多的人',
|
|
||||||
label: '常发',
|
|
||||||
display: topContactMessages.value > 0 ? `${formatInt(topContactMessages.value)} 条` : '—',
|
|
||||||
norm: logNorm(topContactMessages.value, 5)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'topGroupMessages',
|
|
||||||
name: '发言最多的群',
|
|
||||||
label: '发言',
|
|
||||||
display: topGroupMessages.value > 0 ? `${formatInt(topGroupMessages.value)} 条` : '—',
|
|
||||||
norm: logNorm(topGroupMessages.value, 5)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'topKindPct',
|
|
||||||
name: '最常用表达',
|
|
||||||
label: '表达',
|
|
||||||
display: topKindPct.value > 0 ? `${topKindPct.value}%` : '—',
|
|
||||||
norm: clamp01(topKindPct.value / 100)
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const rings = 5
|
|
||||||
const cx = 110
|
|
||||||
const cy = 110
|
|
||||||
const radius = 74
|
|
||||||
|
|
||||||
const axisPoints = computed(() => {
|
|
||||||
const n = metrics.value.length
|
|
||||||
return metrics.value.map((_, idx) => {
|
|
||||||
const a = (Math.PI * 2 * idx) / n - Math.PI / 2
|
|
||||||
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a), a }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const gridPolygonPoints = (t) => {
|
const annualDailyCounts = computed(() => {
|
||||||
const pts = axisPoints.value.map((p) => `${cx + (p.x - cx) * t},${cy + (p.y - cy) * t}`)
|
const a = props.data?.annualHeatmap
|
||||||
return pts.join(' ')
|
const arr = a?.dailyCounts
|
||||||
}
|
return Array.isArray(arr) ? arr : []
|
||||||
|
|
||||||
const dataPoints = computed(() => {
|
|
||||||
const pts = []
|
|
||||||
const n = metrics.value.length
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const m = metrics.value[i]
|
|
||||||
const a = (Math.PI * 2 * i) / n - Math.PI / 2
|
|
||||||
const r = radius * clamp01(m.norm)
|
|
||||||
const x = cx + r * Math.cos(a)
|
|
||||||
const y = cy + r * Math.sin(a)
|
|
||||||
pts.push({ x, y, title: `${m.name}:${m.display}` })
|
|
||||||
}
|
|
||||||
return pts
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dataPolygonPoints = computed(() => dataPoints.value.map((p) => `${p.x},${p.y}`).join(' '))
|
const annualHighlights = computed(() => {
|
||||||
|
const a = props.data?.annualHeatmap
|
||||||
const labels = computed(() => {
|
const hs = a?.highlights
|
||||||
const out = []
|
return Array.isArray(hs) ? hs : []
|
||||||
const n = metrics.value.length
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const m = metrics.value[i]
|
|
||||||
const a = (Math.PI * 2 * i) / n - Math.PI / 2
|
|
||||||
const r = radius + 18
|
|
||||||
const x = cx + r * Math.cos(a)
|
|
||||||
const y = cy + r * Math.sin(a)
|
|
||||||
const cos = Math.cos(a)
|
|
||||||
let anchor = 'middle'
|
|
||||||
if (cos > 0.35) anchor = 'start'
|
|
||||||
else if (cos < -0.35) anchor = 'end'
|
|
||||||
out.push({ x, y, label: m.label, anchor })
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ========== 基础样式 ========== */
|
|
||||||
.overview-card {
|
|
||||||
@apply rounded-2xl border border-[#00000010] bg-white/60 backdrop-blur p-4 sm:p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-grid-line {
|
|
||||||
stroke: rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-axis-line {
|
|
||||||
stroke: rgba(0, 0, 0, 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-data-polygon {
|
|
||||||
fill: rgba(7, 193, 96, 0.18);
|
|
||||||
stroke: rgba(7, 193, 96, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-data-node {
|
|
||||||
fill: #07C160;
|
|
||||||
stroke: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-label {
|
|
||||||
fill: rgba(0, 0, 0, 0.70);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-progress-bg {
|
|
||||||
@apply h-2 flex-1 rounded-full bg-[#0000000d] overflow-hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-progress-fill {
|
|
||||||
@apply h-full rounded-full bg-[#07C160];
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
<div class="rounded-2xl border border-[#00000010] bg-[#F5F5F5] p-3 sm:p-4">
|
<div class="rounded-2xl border border-[#00000010] bg-[#F5F5F5] p-3 sm:p-4">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<!-- Received (left) -->
|
<!-- Received (left) -->
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="avatar-box bg-white">
|
<div class="avatar-box bg-white">
|
||||||
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="#07C160" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="#07C160" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M8 3h10a2 2 0 0 1 2 2v14H6V5a2 2 0 0 1 2-2z" />
|
<path d="M8 3h10a2 2 0 0 1 2 2v14H6V5a2 2 0 0 1 2-2z" />
|
||||||
<path d="M6 7H4a2 2 0 0 0-2 2v10h4" />
|
<path d="M6 7H4a2 2 0 0 0-2 2v10h4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="bubble-left">
|
<div class="px-3 py-2 text-sm max-w-[85%] relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-white text-gray-800 bubble-tail-l">
|
||||||
<div class="wrapped-label text-xs text-[#00000066]">你收到的字</div>
|
<div class="wrapped-label text-xs text-[#00000066]">你收到的字</div>
|
||||||
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6]">
|
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6]">
|
||||||
{{ formatInt(receivedChars) }}
|
{{ formatInt(receivedChars) }}
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sent (right) -->
|
<!-- Sent (right) -->
|
||||||
<div class="flex items-end gap-2 justify-end">
|
<div class="flex items-start gap-2 justify-end">
|
||||||
<div class="bubble-right">
|
<div class="px-3 py-2 text-sm max-w-[85%] relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r">
|
||||||
<div class="wrapped-label text-xs text-[#00000080] text-right">你发送的字</div>
|
<div class="wrapped-label text-xs text-[#00000080] text-right">你发送的字</div>
|
||||||
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6] text-right">
|
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6] text-right">
|
||||||
{{ formatInt(sentChars) }}
|
{{ formatInt(sentChars) }}
|
||||||
@@ -321,40 +321,6 @@ const getLabelStyle = (code) => {
|
|||||||
@apply w-8 h-8 rounded-lg border border-[#00000010] flex items-center justify-center flex-shrink-0;
|
@apply w-8 h-8 rounded-lg border border-[#00000010] flex items-center justify-center flex-shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 气泡 - 左侧 */
|
|
||||||
.bubble-left {
|
|
||||||
@apply relative max-w-[85%] bg-white shadow-sm rounded-xl px-3 py-2;
|
|
||||||
}
|
|
||||||
.bubble-left::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -6px;
|
|
||||||
bottom: 8px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-top: 6px solid transparent;
|
|
||||||
border-bottom: 6px solid transparent;
|
|
||||||
border-right: 6px solid #fff;
|
|
||||||
filter: drop-shadow(-1px 0 0 rgba(0,0,0,0.05));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 气泡 - 右侧 */
|
|
||||||
.bubble-right {
|
|
||||||
@apply relative max-w-[85%] bg-[#95EC69] shadow-sm rounded-xl px-3 py-2;
|
|
||||||
}
|
|
||||||
.bubble-right::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
right: -6px;
|
|
||||||
bottom: 8px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-top: 6px solid transparent;
|
|
||||||
border-bottom: 6px solid transparent;
|
|
||||||
border-left: 6px solid #95EC69;
|
|
||||||
filter: drop-shadow(1px 0 0 rgba(0,0,0,0.05));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 键盘外框 */
|
/* 键盘外框 */
|
||||||
.keyboard-outer {
|
.keyboard-outer {
|
||||||
@apply mt-3 rounded-2xl p-1;
|
@apply mt-3 rounded-2xl p-1;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import sqlite3
|
|||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
_MD5_HEX_RE = re.compile(r"(?i)[0-9a-f]{32}")
|
_MD5_HEX_RE = re.compile(r"(?i)[0-9a-f]{32}")
|
||||||
|
_EMOJI_CHAR_RE = re.compile(r"[\U0001F300-\U0001FAFF\u2600-\u27BF]")
|
||||||
# Best-effort heuristics for "new friends added" detection: WeChat system messages vary by version.
|
# Best-effort heuristics for "new friends added" detection: WeChat system messages vary by version.
|
||||||
_ADDED_FRIEND_PATTERNS: tuple[str, ...] = (
|
_ADDED_FRIEND_PATTERNS: tuple[str, ...] = (
|
||||||
"你已添加了",
|
"你已添加了",
|
||||||
@@ -60,6 +61,13 @@ def _year_range_epoch_seconds(year: int) -> tuple[int, int]:
|
|||||||
return start, end
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _days_in_year(year: int) -> int:
|
||||||
|
try:
|
||||||
|
return int((datetime(int(year) + 1, 1, 1) - datetime(int(year), 1, 1)).days)
|
||||||
|
except Exception:
|
||||||
|
return 365
|
||||||
|
|
||||||
|
|
||||||
def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
|
def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
|
||||||
try:
|
try:
|
||||||
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||||
@@ -76,6 +84,494 @@ def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _accumulate_db_daily_counts(
|
||||||
|
*,
|
||||||
|
db_path: Path,
|
||||||
|
start_ts: int,
|
||||||
|
end_ts: int,
|
||||||
|
counts: list[int],
|
||||||
|
sender_username: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Accumulate per-day message counts from one message shard DB into counts list.
|
||||||
|
|
||||||
|
Returns the number of messages counted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
conn: sqlite3.Connection | None = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
|
||||||
|
tables = _list_message_tables(conn)
|
||||||
|
if not tables:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Convert millisecond timestamps defensively.
|
||||||
|
# The expression yields epoch seconds as INTEGER.
|
||||||
|
ts_expr = (
|
||||||
|
"CASE WHEN create_time > 1000000000000 THEN CAST(create_time/1000 AS INTEGER) ELSE create_time END"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional sender filter (best-effort). When provided, we only count
|
||||||
|
# messages whose `real_sender_id` maps to `sender_username`.
|
||||||
|
sender_rowid: int | None = None
|
||||||
|
if sender_username and str(sender_username).strip():
|
||||||
|
try:
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1",
|
||||||
|
(str(sender_username).strip(),),
|
||||||
|
).fetchone()
|
||||||
|
if r is not None and r[0] is not None:
|
||||||
|
sender_rowid = int(r[0])
|
||||||
|
except Exception:
|
||||||
|
sender_rowid = None
|
||||||
|
|
||||||
|
counted = 0
|
||||||
|
for table_name in tables:
|
||||||
|
qt = _quote_ident(table_name)
|
||||||
|
sender_where = ""
|
||||||
|
params: tuple[Any, ...]
|
||||||
|
if sender_rowid is not None:
|
||||||
|
sender_where = " AND real_sender_id = ?"
|
||||||
|
params = (start_ts, end_ts, sender_rowid)
|
||||||
|
else:
|
||||||
|
params = (start_ts, end_ts)
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
"SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
"COUNT(1) AS cnt "
|
||||||
|
"FROM ("
|
||||||
|
f" SELECT {ts_expr} AS ts"
|
||||||
|
f" FROM {qt}"
|
||||||
|
f" WHERE {ts_expr} >= ? AND {ts_expr} < ?{sender_where}"
|
||||||
|
") sub "
|
||||||
|
"GROUP BY doy"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for doy, cnt in rows:
|
||||||
|
try:
|
||||||
|
d = int(doy if doy is not None else -1)
|
||||||
|
c = int(cnt or 0)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if c <= 0 or d < 0 or d >= len(counts):
|
||||||
|
continue
|
||||||
|
counts[d] += c
|
||||||
|
counted += c
|
||||||
|
|
||||||
|
return counted
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def compute_annual_daily_counts(*, account_dir: Path, year: int, sender_username: str | None = None) -> list[int]:
|
||||||
|
"""Compute per-day message counts for the given year.
|
||||||
|
|
||||||
|
The output is a 0-indexed day-of-year list (length 365/366). Counts default to
|
||||||
|
"messages sent by me" when sender_username is provided.
|
||||||
|
"""
|
||||||
|
|
||||||
|
start_ts, end_ts = _year_range_epoch_seconds(year)
|
||||||
|
days = _days_in_year(year)
|
||||||
|
counts: list[int] = [0 for _ in range(days)]
|
||||||
|
|
||||||
|
sender = str(sender_username or "").strip()
|
||||||
|
|
||||||
|
# Prefer using our unified search index if available; it's much faster than scanning all msg tables.
|
||||||
|
index_path = get_chat_search_index_db_path(account_dir)
|
||||||
|
if index_path.exists():
|
||||||
|
conn = sqlite3.connect(str(index_path))
|
||||||
|
try:
|
||||||
|
has_fts = (
|
||||||
|
conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_fts' LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if has_fts:
|
||||||
|
# Convert millisecond timestamps defensively (some datasets store ms).
|
||||||
|
ts_expr = (
|
||||||
|
"CASE "
|
||||||
|
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
|
||||||
|
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
|
||||||
|
"ELSE CAST(create_time AS INTEGER) "
|
||||||
|
"END"
|
||||||
|
)
|
||||||
|
sender_clause = ""
|
||||||
|
if sender:
|
||||||
|
sender_clause = " AND sender_username = ?"
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
"SELECT "
|
||||||
|
"CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
"COUNT(1) AS cnt "
|
||||||
|
"FROM ("
|
||||||
|
f" SELECT {ts_expr} AS ts"
|
||||||
|
" FROM message_fts"
|
||||||
|
f" WHERE {ts_expr} >= ? AND {ts_expr} < ?"
|
||||||
|
" AND db_stem NOT LIKE 'biz_message%'"
|
||||||
|
f"{sender_clause}"
|
||||||
|
") sub "
|
||||||
|
"GROUP BY doy"
|
||||||
|
)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
params: tuple[Any, ...] = (start_ts, end_ts)
|
||||||
|
if sender:
|
||||||
|
params = (start_ts, end_ts, sender)
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for r in rows:
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
doy = int(r[0] if r[0] is not None else -1)
|
||||||
|
cnt = int(r[1] or 0)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if cnt <= 0 or doy < 0 or doy >= days:
|
||||||
|
continue
|
||||||
|
counts[doy] += cnt
|
||||||
|
total += cnt
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Wrapped annual heatmap computed (search index): account=%s year=%s total=%s sender=%s db=%s elapsed=%.2fs",
|
||||||
|
str(account_dir.name or "").strip(),
|
||||||
|
year,
|
||||||
|
total,
|
||||||
|
sender or "*",
|
||||||
|
str(index_path.name),
|
||||||
|
time.time() - t0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return counts
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db_paths = _iter_message_db_paths(account_dir)
|
||||||
|
# Default: exclude official/biz shards (biz_message*.db) to reduce noise.
|
||||||
|
db_paths = [p for p in db_paths if not p.name.lower().startswith("biz_message")]
|
||||||
|
my_wxid = str(account_dir.name or "").strip()
|
||||||
|
t0 = time.time()
|
||||||
|
total = 0
|
||||||
|
for db_path in db_paths:
|
||||||
|
total += _accumulate_db_daily_counts(
|
||||||
|
db_path=db_path,
|
||||||
|
start_ts=start_ts,
|
||||||
|
end_ts=end_ts,
|
||||||
|
counts=counts,
|
||||||
|
sender_username=sender or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Wrapped annual heatmap computed: account=%s year=%s total=%s sender=%s dbs=%s elapsed=%.2fs",
|
||||||
|
my_wxid,
|
||||||
|
year,
|
||||||
|
total,
|
||||||
|
sender or "*",
|
||||||
|
len(db_paths),
|
||||||
|
time.time() - t0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _ymd_from_doy(*, year: int, doy: int) -> str:
|
||||||
|
try:
|
||||||
|
dt = datetime(int(year), 1, 1) + timedelta(days=int(doy))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return dt.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _best_doy_by_max(values: list[int]) -> tuple[int, int] | tuple[None, int]:
|
||||||
|
best_doy: int | None = None
|
||||||
|
best = 0
|
||||||
|
for i, v in enumerate(values):
|
||||||
|
try:
|
||||||
|
n = int(v or 0)
|
||||||
|
except Exception:
|
||||||
|
n = 0
|
||||||
|
if n > best or (n == best and best_doy is not None and i < best_doy):
|
||||||
|
best = n
|
||||||
|
best_doy = i
|
||||||
|
return best_doy, best
|
||||||
|
|
||||||
|
|
||||||
|
def compute_annual_heatmap_highlights(
|
||||||
|
*,
|
||||||
|
account_dir: Path,
|
||||||
|
year: int,
|
||||||
|
sender_username: str,
|
||||||
|
sent_daily_counts: list[int],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Compute special day highlights for the annual calendar heatmap (best-effort).
|
||||||
|
|
||||||
|
We prefer the unified search index when available; fallback mode returns the subset
|
||||||
|
that can be derived from `sent_daily_counts` without scanning all message content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
days = int(len(sent_daily_counts) or _days_in_year(year))
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def add_highlight(
|
||||||
|
*,
|
||||||
|
key: str,
|
||||||
|
label: str,
|
||||||
|
doy: int,
|
||||||
|
value: int | None = None,
|
||||||
|
value_label: str = "",
|
||||||
|
date: str = "",
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
d = int(doy)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if d < 0 or d >= days:
|
||||||
|
return
|
||||||
|
ymd = str(date or "") or _ymd_from_doy(year=int(year), doy=d)
|
||||||
|
if not ymd:
|
||||||
|
return
|
||||||
|
obj: dict[str, Any] = {
|
||||||
|
"key": str(key),
|
||||||
|
"label": str(label),
|
||||||
|
"doy": int(d),
|
||||||
|
"date": ymd,
|
||||||
|
}
|
||||||
|
if value is not None:
|
||||||
|
try:
|
||||||
|
obj["value"] = int(value)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if value_label:
|
||||||
|
obj["valueLabel"] = str(value_label)
|
||||||
|
out.append(obj)
|
||||||
|
|
||||||
|
# 3. Sent messages max day (always available from sent daily counts)
|
||||||
|
best_doy, best_val = _best_doy_by_max(sent_daily_counts or [])
|
||||||
|
if best_doy is not None and best_val > 0:
|
||||||
|
add_highlight(key="sent_messages_max", label="发送消息条数最多的一天", doy=int(best_doy), value=int(best_val), value_label=f"{int(best_val)} 条")
|
||||||
|
|
||||||
|
sender = str(sender_username or "").strip()
|
||||||
|
if not sender:
|
||||||
|
return out
|
||||||
|
|
||||||
|
index_path = get_chat_search_index_db_path(account_dir)
|
||||||
|
if not index_path.exists():
|
||||||
|
return out
|
||||||
|
|
||||||
|
start_ts, end_ts = _year_range_epoch_seconds(year)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(index_path))
|
||||||
|
try:
|
||||||
|
has_fts = (
|
||||||
|
conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_fts' LIMIT 1").fetchone()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not has_fts:
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Convert millisecond timestamps defensively (some datasets store ms).
|
||||||
|
ts_expr = (
|
||||||
|
"CASE "
|
||||||
|
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
|
||||||
|
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
|
||||||
|
"ELSE CAST(create_time AS INTEGER) "
|
||||||
|
"END"
|
||||||
|
)
|
||||||
|
|
||||||
|
base_where = f"{ts_expr} >= ? AND {ts_expr} < ? AND db_stem NOT LIKE 'biz_message%'"
|
||||||
|
base_params: tuple[Any, ...] = (start_ts, end_ts)
|
||||||
|
|
||||||
|
def fetch_best_doy_value(sql: str, params: tuple[Any, ...]) -> tuple[int, int] | None:
|
||||||
|
try:
|
||||||
|
r = conn.execute(sql, params).fetchone()
|
||||||
|
except Exception:
|
||||||
|
r = None
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
doy = int(r[0] if r[0] is not None else -1)
|
||||||
|
val = int(r[1] or 0)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if val <= 0 or doy < 0 or doy >= days:
|
||||||
|
return None
|
||||||
|
return doy, val
|
||||||
|
|
||||||
|
# 1. Sent chars max day (text messages only, non-whitespace approximation)
|
||||||
|
char_expr = (
|
||||||
|
"length(replace(replace(replace(replace(coalesce(text,''),' ',''), char(10), ''), char(13), ''), char(9), ''))"
|
||||||
|
)
|
||||||
|
sql_sent_chars = (
|
||||||
|
"SELECT doy, chars FROM ("
|
||||||
|
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
f" SUM({char_expr}) AS chars "
|
||||||
|
" FROM ("
|
||||||
|
f" SELECT {ts_expr} AS ts, text "
|
||||||
|
" FROM message_fts "
|
||||||
|
f" WHERE {base_where} AND sender_username = ? AND CAST(local_type AS INTEGER) = 1"
|
||||||
|
" ) sub "
|
||||||
|
" GROUP BY doy"
|
||||||
|
") t ORDER BY chars DESC, doy ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
r_sent_chars = fetch_best_doy_value(sql_sent_chars, base_params + (sender,))
|
||||||
|
if r_sent_chars is not None:
|
||||||
|
doy, v = r_sent_chars
|
||||||
|
add_highlight(key="sent_chars_max", label="发送字最多的一天", doy=doy, value=v, value_label=f"{v} 字")
|
||||||
|
|
||||||
|
# 2. Received chars max day (text messages only)
|
||||||
|
sql_recv_chars = (
|
||||||
|
"SELECT doy, chars FROM ("
|
||||||
|
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
f" SUM({char_expr}) AS chars "
|
||||||
|
" FROM ("
|
||||||
|
f" SELECT {ts_expr} AS ts, text "
|
||||||
|
" FROM message_fts "
|
||||||
|
f" WHERE {base_where} AND COALESCE(sender_username,'') != ? AND CAST(local_type AS INTEGER) = 1"
|
||||||
|
" ) sub "
|
||||||
|
" GROUP BY doy"
|
||||||
|
") t ORDER BY chars DESC, doy ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
r_recv_chars = fetch_best_doy_value(sql_recv_chars, base_params + (sender,))
|
||||||
|
if r_recv_chars is not None:
|
||||||
|
doy, v = r_recv_chars
|
||||||
|
add_highlight(key="received_chars_max", label="接收字最多的一天", doy=doy, value=v, value_label=f"{v} 字")
|
||||||
|
|
||||||
|
# 4. Received message count max day (exclude system messages)
|
||||||
|
sql_recv_msgs = (
|
||||||
|
"SELECT doy, cnt FROM ("
|
||||||
|
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
" COUNT(1) AS cnt "
|
||||||
|
" FROM ("
|
||||||
|
f" SELECT {ts_expr} AS ts "
|
||||||
|
" FROM message_fts "
|
||||||
|
f" WHERE {base_where} AND COALESCE(sender_username,'') != ? AND CAST(local_type AS INTEGER) != 10000"
|
||||||
|
" ) sub "
|
||||||
|
" GROUP BY doy"
|
||||||
|
") t ORDER BY cnt DESC, doy ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
r_recv_msgs = fetch_best_doy_value(sql_recv_msgs, base_params + (sender,))
|
||||||
|
if r_recv_msgs is not None:
|
||||||
|
doy, v = r_recv_msgs
|
||||||
|
add_highlight(key="received_messages_max", label="接收消息条数最多的一天", doy=doy, value=v, value_label=f"{v} 条")
|
||||||
|
|
||||||
|
# 5. Added friends max day (best-effort via system message patterns, exclude official/chatrooms)
|
||||||
|
added_like_patterns = [f"%{p}%" for p in _ADDED_FRIEND_PATTERNS if str(p or "").strip()]
|
||||||
|
if added_like_patterns:
|
||||||
|
cond_added = " OR ".join(["text LIKE ?"] * len(added_like_patterns))
|
||||||
|
sql_added = (
|
||||||
|
"SELECT doy, cnt FROM ("
|
||||||
|
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
" COUNT(DISTINCT username) AS cnt "
|
||||||
|
" FROM ("
|
||||||
|
f" SELECT {ts_expr} AS ts, username, text "
|
||||||
|
" FROM message_fts "
|
||||||
|
f" WHERE {base_where} "
|
||||||
|
" AND CAST(local_type AS INTEGER) = 10000 "
|
||||||
|
" AND COALESCE(is_official, 0) = 0 "
|
||||||
|
" AND username NOT LIKE '%@chatroom' "
|
||||||
|
f" AND ({cond_added})"
|
||||||
|
" ) sub "
|
||||||
|
" GROUP BY doy"
|
||||||
|
") t ORDER BY cnt DESC, doy ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
params_added: tuple[Any, ...] = base_params + tuple(added_like_patterns)
|
||||||
|
r_added = fetch_best_doy_value(sql_added, params_added)
|
||||||
|
if r_added is not None:
|
||||||
|
doy, v = r_added
|
||||||
|
add_highlight(key="added_friends_max", label="加好友最多的一天", doy=doy, value=v, value_label=f"{v} 位")
|
||||||
|
|
||||||
|
# 6. Sticker/emoji messages max day (WeChat local_type=47)
|
||||||
|
sql_emoji_msgs = (
|
||||||
|
"SELECT doy, cnt FROM ("
|
||||||
|
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
" COUNT(1) AS cnt "
|
||||||
|
" FROM ("
|
||||||
|
f" SELECT {ts_expr} AS ts "
|
||||||
|
" FROM message_fts "
|
||||||
|
f" WHERE {base_where} AND sender_username = ? AND CAST(local_type AS INTEGER) = 47"
|
||||||
|
" ) sub "
|
||||||
|
" GROUP BY doy"
|
||||||
|
") t ORDER BY cnt DESC, doy ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
r_emoji_msgs = fetch_best_doy_value(sql_emoji_msgs, base_params + (sender,))
|
||||||
|
if r_emoji_msgs is not None:
|
||||||
|
doy, v = r_emoji_msgs
|
||||||
|
add_highlight(key="sticker_messages_max", label="发表情包最多的一天", doy=doy, value=v, value_label=f"{v} 条")
|
||||||
|
|
||||||
|
# 7. Unicode emoji chars max day (best-effort; count emoji codepoints in sent text)
|
||||||
|
emoji_counts: list[int] = [0 for _ in range(days)]
|
||||||
|
sql_emoji_text = (
|
||||||
|
"SELECT doy, text FROM ("
|
||||||
|
" SELECT "
|
||||||
|
" CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
" text "
|
||||||
|
" FROM ("
|
||||||
|
f" SELECT {ts_expr} AS ts, text "
|
||||||
|
" FROM message_fts "
|
||||||
|
f" WHERE {base_where} AND sender_username = ? AND CAST(local_type AS INTEGER) = 1"
|
||||||
|
" ) sub2"
|
||||||
|
") sub "
|
||||||
|
"WHERE text IS NOT NULL AND text != ''"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cur = conn.execute(sql_emoji_text, base_params + (sender,))
|
||||||
|
except Exception:
|
||||||
|
cur = None
|
||||||
|
if cur is not None:
|
||||||
|
for r in cur:
|
||||||
|
try:
|
||||||
|
doy = int(r[0] if r and r[0] is not None else -1)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if doy < 0 or doy >= days:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
txt = str(r[1] or "")
|
||||||
|
except Exception:
|
||||||
|
txt = ""
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
emoji_counts[doy] += len(_EMOJI_CHAR_RE.findall(txt))
|
||||||
|
|
||||||
|
best_emoji_doy, best_emoji = _best_doy_by_max(emoji_counts)
|
||||||
|
if best_emoji_doy is not None and best_emoji > 0:
|
||||||
|
add_highlight(
|
||||||
|
key="emoji_chars_max",
|
||||||
|
label="发emoji最多的一天",
|
||||||
|
doy=int(best_emoji_doy),
|
||||||
|
value=int(best_emoji),
|
||||||
|
value_label=f"{int(best_emoji)} 个",
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _list_session_usernames(session_db_path: Path) -> list[str]:
|
def _list_session_usernames(session_db_path: Path) -> list[str]:
|
||||||
if not session_db_path.exists():
|
if not session_db_path.exists():
|
||||||
return []
|
return []
|
||||||
@@ -769,6 +1265,22 @@ def build_card_00_global_overview(
|
|||||||
"action": "你还在微信里发送消息",
|
"action": "你还在微信里发送消息",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
daily_counts = compute_annual_daily_counts(account_dir=account_dir, year=year, sender_username=sender)
|
||||||
|
annual_highlights = compute_annual_heatmap_highlights(
|
||||||
|
account_dir=account_dir,
|
||||||
|
year=year,
|
||||||
|
sender_username=sender,
|
||||||
|
sent_daily_counts=daily_counts,
|
||||||
|
)
|
||||||
|
annual_heatmap = {
|
||||||
|
"year": int(year),
|
||||||
|
"startDate": f"{int(year)}-01-01",
|
||||||
|
"endDate": f"{int(year)}-12-31",
|
||||||
|
"days": int(len(daily_counts)),
|
||||||
|
"dailyCounts": daily_counts,
|
||||||
|
"highlights": annual_highlights,
|
||||||
|
}
|
||||||
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
if heatmap.total_messages > 0:
|
if heatmap.total_messages > 0:
|
||||||
lines.append(f"今年以来,你在微信里发送了 {heatmap.total_messages:,} 条消息,平均每天 {messages_per_day:.1f} 条。")
|
lines.append(f"今年以来,你在微信里发送了 {heatmap.total_messages:,} 条消息,平均每天 {messages_per_day:.1f} 条。")
|
||||||
@@ -816,6 +1328,8 @@ def build_card_00_global_overview(
|
|||||||
"totalMessages": int(heatmap.total_messages),
|
"totalMessages": int(heatmap.total_messages),
|
||||||
"activeDays": int(stats.active_days),
|
"activeDays": int(stats.active_days),
|
||||||
"addedFriends": int(stats.added_friends),
|
"addedFriends": int(stats.added_friends),
|
||||||
|
"sentMediaCount": int(stats.kind_counts.get("image", 0) + stats.kind_counts.get("video", 0)),
|
||||||
|
"sentStickerCount": int(stats.kind_counts.get("emoji", 0)),
|
||||||
"messagesPerDay": messages_per_day,
|
"messagesPerDay": messages_per_day,
|
||||||
"mostActiveHour": most_active_hour,
|
"mostActiveHour": most_active_hour,
|
||||||
"mostActiveWeekday": most_active_weekday,
|
"mostActiveWeekday": most_active_weekday,
|
||||||
@@ -823,6 +1337,7 @@ def build_card_00_global_overview(
|
|||||||
"topContact": top_contact_obj,
|
"topContact": top_contact_obj,
|
||||||
"topGroup": top_group_obj,
|
"topGroup": top_group_obj,
|
||||||
"topKind": top_kind,
|
"topKind": top_kind,
|
||||||
|
"annualHeatmap": annual_heatmap,
|
||||||
"topPhrase": {"phrase": stats.top_phrase[0], "count": int(stats.top_phrase[1])} if stats.top_phrase else None,
|
"topPhrase": {"phrase": stats.top_phrase[0], "count": int(stats.top_phrase[1])} if stats.top_phrase else None,
|
||||||
"topEmoji": {"emoji": stats.top_emoji[0], "count": int(stats.top_emoji[1])} if stats.top_emoji else None,
|
"topEmoji": {"emoji": stats.top_emoji[0], "count": int(stats.top_emoji[1])} if stats.top_emoji else None,
|
||||||
"highlight": highlight,
|
"highlight": highlight,
|
||||||
|
|||||||
Reference in New Issue
Block a user