mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
improvement(wrapped-ui): 优化年度总结可视化表现并适配主题
- 热力图支持按主题着色(Game Boy/DOS/VHS),并补充对应主题样式 - 字数键盘引入 10 级磨损系统:磨损/标注按等级平滑变化,并中文化提示 - 作息卡补充基于热力图的个性化叙事文案
This commit is contained in:
@@ -1,5 +1,71 @@
|
|||||||
<template>
|
<template>
|
||||||
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative" :variant="variant">
|
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
|
||||||
|
<template #narrative>
|
||||||
|
<div class="mt-2 wrapped-body text-sm text-[#7F7F7F] leading-relaxed">
|
||||||
|
<p>
|
||||||
|
<template v-if="totalMessages <= 0">
|
||||||
|
今年你没有发出聊天消息
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="personality === 'early_bird'">
|
||||||
|
清晨
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ pad2(mostActiveHour) }}</span>:00,
|
||||||
|
当城市还在沉睡,你已经开始了新一天的问候。
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveWeekdayName }}</span>
|
||||||
|
是你最健谈的一天,这一年你用
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(totalMessages) }}</span>
|
||||||
|
条消息记录了这些早起时光。
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="personality === 'office_worker'">
|
||||||
|
忙碌的上午
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ pad2(mostActiveHour) }}</span>:00,
|
||||||
|
是你最常敲击键盘的时刻。
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveWeekdayName }}</span>
|
||||||
|
最活跃,这一年你用
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(totalMessages) }}</span>
|
||||||
|
条消息把工作与生活都留在了对话里。
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="personality === 'afternoon'">
|
||||||
|
午后的阳光里,
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ pad2(mostActiveHour) }}</span>:00
|
||||||
|
是你最爱分享的时刻。
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveWeekdayName }}</span>
|
||||||
|
的聊天最热闹,这一年共
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(totalMessages) }}</span>
|
||||||
|
条消息<span class="whitespace-nowrap">串起了</span>你的午后时光。
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="personality === 'night_owl'">
|
||||||
|
夜幕降临,
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ pad2(mostActiveHour) }}</span>:00
|
||||||
|
是你最常出没的时刻。
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveWeekdayName }}</span>
|
||||||
|
最活跃,这一年
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(totalMessages) }}</span>
|
||||||
|
条消息陪你把每个夜晚都聊得更亮。
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="personality === 'late_night'">
|
||||||
|
当世界沉睡,凌晨
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ pad2(mostActiveHour) }}</span>:00
|
||||||
|
的你依然在线。
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveWeekdayName }}</span>
|
||||||
|
最活跃,这一年
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(totalMessages) }}</span>
|
||||||
|
条深夜消息,是你与这个世界的悄悄话。
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
你在
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ pad2(mostActiveHour) }}</span>:00
|
||||||
|
最活跃
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<WeekdayHourHeatmap
|
<WeekdayHourHeatmap
|
||||||
:weekday-labels="card.data?.weekdayLabels"
|
:weekday-labels="card.data?.weekdayLabels"
|
||||||
:hour-labels="card.data?.hourLabels"
|
:hour-labels="card.data?.hourLabels"
|
||||||
@@ -10,8 +76,95 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
card: { type: Object, required: true },
|
card: { type: Object, required: true },
|
||||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const _DEFAULT_WEEKDAYS_ZH = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
|
|
||||||
|
const weekdayLabels = computed(() => {
|
||||||
|
const labels = props.card?.data?.weekdayLabels
|
||||||
|
if (Array.isArray(labels) && labels.length >= 7) return labels
|
||||||
|
return _DEFAULT_WEEKDAYS_ZH
|
||||||
|
})
|
||||||
|
|
||||||
|
const matrix = computed(() => {
|
||||||
|
const m = props.card?.data?.matrix
|
||||||
|
return Array.isArray(m) ? m : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalMessages = computed(() => Number(props.card?.data?.totalMessages || 0))
|
||||||
|
|
||||||
|
const mostActiveHour = computed(() => {
|
||||||
|
if (!matrix.value || !Array.isArray(matrix.value) || matrix.value.length < 7) return null
|
||||||
|
|
||||||
|
let bestH = 0
|
||||||
|
let bestTotal = -1
|
||||||
|
|
||||||
|
for (let h = 0; h < 24; h += 1) {
|
||||||
|
let total = 0
|
||||||
|
for (let w = 0; w < 7; w += 1) {
|
||||||
|
const row = matrix.value[w]
|
||||||
|
if (!Array.isArray(row) || row.length < 24) continue
|
||||||
|
const v = Number(row[h] || 0)
|
||||||
|
if (Number.isFinite(v)) total += v
|
||||||
|
}
|
||||||
|
// Tie-breaker: pick earliest hour.
|
||||||
|
if (total > bestTotal || (total === bestTotal && h < bestH)) {
|
||||||
|
bestTotal = total
|
||||||
|
bestH = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestTotal >= 0 ? bestH : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const mostActiveWeekdayIndex = computed(() => {
|
||||||
|
if (!matrix.value || !Array.isArray(matrix.value) || matrix.value.length < 7) return null
|
||||||
|
|
||||||
|
let bestW = 0
|
||||||
|
let bestTotal = -1
|
||||||
|
|
||||||
|
for (let w = 0; w < 7; w += 1) {
|
||||||
|
const row = matrix.value[w]
|
||||||
|
if (!Array.isArray(row) || row.length < 24) continue
|
||||||
|
let total = 0
|
||||||
|
for (let h = 0; h < 24; h += 1) {
|
||||||
|
const v = Number(row[h] || 0)
|
||||||
|
if (Number.isFinite(v)) total += v
|
||||||
|
}
|
||||||
|
// Tie-breaker: pick earliest weekday.
|
||||||
|
if (total > bestTotal || (total === bestTotal && w < bestW)) {
|
||||||
|
bestTotal = total
|
||||||
|
bestW = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestTotal >= 0 ? bestW : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const mostActiveWeekdayName = computed(() => {
|
||||||
|
const idx = mostActiveWeekdayIndex.value
|
||||||
|
if (idx === null) return ''
|
||||||
|
return String(weekdayLabels.value[idx] || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const personality = computed(() => {
|
||||||
|
const hour = mostActiveHour.value
|
||||||
|
if (hour === null) return 'unknown'
|
||||||
|
if (hour >= 5 && hour <= 8) return 'early_bird'
|
||||||
|
if (hour >= 9 && hour <= 12) return 'office_worker'
|
||||||
|
if (hour >= 13 && hour <= 17) return 'afternoon'
|
||||||
|
if (hour >= 18 && hour <= 23) return 'night_owl'
|
||||||
|
if (hour >= 0 && hour <= 4) return 'late_night'
|
||||||
|
return 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||||
|
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||||
|
|
||||||
|
const pad2 = (h) => String(Number(h ?? 0)).padStart(2, '0')
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="rounded-2xl border border-[#00000010] bg-white/60 backdrop-blur p-4 sm:p-6">
|
<div class="overview-card">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="wrapped-label text-xs text-[#00000066]">年度聊天画像</div>
|
<div class="wrapped-label text-xs text-[#00000066]">年度聊天画像</div>
|
||||||
<div class="wrapped-body text-xs text-[#00000066]">Radar</div>
|
<div class="wrapped-body text-xs text-[#00000066]">Radar</div>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
:key="i"
|
:key="i"
|
||||||
:points="gridPolygonPoints(i / rings)"
|
:points="gridPolygonPoints(i / rings)"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(0,0,0,0.08)"
|
class="overview-grid-line"
|
||||||
stroke-width="1"
|
stroke-width="1"
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
:y1="cy"
|
:y1="cy"
|
||||||
:x2="p.x"
|
:x2="p.x"
|
||||||
:y2="p.y"
|
:y2="p.y"
|
||||||
stroke="rgba(0,0,0,0.10)"
|
class="overview-axis-line"
|
||||||
stroke-width="1"
|
stroke-width="1"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
@@ -34,8 +34,7 @@
|
|||||||
<!-- Data polygon -->
|
<!-- Data polygon -->
|
||||||
<polygon
|
<polygon
|
||||||
:points="dataPolygonPoints"
|
:points="dataPolygonPoints"
|
||||||
fill="rgba(7,193,96,0.18)"
|
class="overview-data-polygon"
|
||||||
stroke="rgba(7,193,96,0.85)"
|
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -47,8 +46,7 @@
|
|||||||
:cx="p.x"
|
:cx="p.x"
|
||||||
:cy="p.y"
|
:cy="p.y"
|
||||||
r="4"
|
r="4"
|
||||||
fill="#07C160"
|
class="overview-data-node"
|
||||||
stroke="white"
|
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
>
|
>
|
||||||
<title>{{ p.title }}</title>
|
<title>{{ p.title }}</title>
|
||||||
@@ -65,7 +63,7 @@
|
|||||||
:text-anchor="l.anchor"
|
:text-anchor="l.anchor"
|
||||||
dominant-baseline="middle"
|
dominant-baseline="middle"
|
||||||
font-size="11"
|
font-size="11"
|
||||||
fill="rgba(0,0,0,0.70)"
|
class="overview-label"
|
||||||
>
|
>
|
||||||
{{ l.label }}
|
{{ l.label }}
|
||||||
</text>
|
</text>
|
||||||
@@ -81,8 +79,8 @@
|
|||||||
>
|
>
|
||||||
<div class="wrapped-body text-sm text-[#00000099]">{{ m.name }}</div>
|
<div class="wrapped-body text-sm text-[#00000099]">{{ m.name }}</div>
|
||||||
<div class="flex items-center gap-3 min-w-[160px]">
|
<div class="flex items-center gap-3 min-w-[160px]">
|
||||||
<div class="h-2 flex-1 rounded-full bg-[#0000000d] overflow-hidden">
|
<div class="overview-progress-bg">
|
||||||
<div class="h-full rounded-full bg-[#07C160]" :style="{ width: Math.round(m.norm * 100) + '%' }" />
|
<div class="overview-progress-fill" :style="{ width: Math.round(m.norm * 100) + '%' }" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
@@ -234,3 +232,201 @@ const labels = computed(() => {
|
|||||||
return out
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Game Boy 主题 ========== */
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .overview-card {
|
||||||
|
background: #8bac0f !important;
|
||||||
|
border: 4px solid #306230 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
backdrop-filter: none;
|
||||||
|
box-shadow:
|
||||||
|
inset -2px -2px 0 0 #306230,
|
||||||
|
inset 2px 2px 0 0 #c5d870;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .overview-progress-bg {
|
||||||
|
background: #306230 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .overview-progress-fill {
|
||||||
|
background: #0f380f !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .overview-grid-line {
|
||||||
|
stroke: #306230;
|
||||||
|
stroke-opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .overview-axis-line {
|
||||||
|
stroke: #306230;
|
||||||
|
stroke-opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .overview-data-polygon {
|
||||||
|
fill: rgba(15, 56, 15, 0.3);
|
||||||
|
stroke: #0f380f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .overview-data-node {
|
||||||
|
fill: #0f380f;
|
||||||
|
stroke: #9bbc0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .overview-label {
|
||||||
|
fill: #0f380f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .wrapped-label,
|
||||||
|
.wrapped-theme-gameboy .wrapped-body {
|
||||||
|
color: #306230 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .wrapped-number {
|
||||||
|
color: #0f380f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== DOS 主题 ========== */
|
||||||
|
|
||||||
|
.wrapped-theme-dos .overview-card {
|
||||||
|
background: #0a0a0a !important;
|
||||||
|
border: 1px solid #33ff33 !important;
|
||||||
|
box-shadow: 0 0 10px rgba(51, 255, 51, 0.2);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .overview-grid-line {
|
||||||
|
stroke: #33ff33;
|
||||||
|
stroke-opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .overview-axis-line {
|
||||||
|
stroke: #33ff33;
|
||||||
|
stroke-opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .overview-data-polygon {
|
||||||
|
fill: rgba(51, 255, 51, 0.15);
|
||||||
|
stroke: #33ff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .overview-data-node {
|
||||||
|
fill: #33ff33;
|
||||||
|
stroke: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .overview-label {
|
||||||
|
fill: #33ff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .overview-progress-bg {
|
||||||
|
background: #1a1a1a !important;
|
||||||
|
border: 1px solid #33ff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .overview-progress-fill {
|
||||||
|
background: #33ff33 !important;
|
||||||
|
box-shadow: 0 0 5px #33ff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .wrapped-label,
|
||||||
|
.wrapped-theme-dos .wrapped-body {
|
||||||
|
color: #22aa22 !important;
|
||||||
|
text-shadow: 0 0 3px #22aa22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .wrapped-number {
|
||||||
|
color: #33ff33 !important;
|
||||||
|
text-shadow: 0 0 5px #33ff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== VHS 主题 ========== */
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .overview-card {
|
||||||
|
background: #16213e !important;
|
||||||
|
border: 1px solid #0f3460 !important;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .overview-grid-line {
|
||||||
|
stroke: #0f3460;
|
||||||
|
stroke-opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .overview-axis-line {
|
||||||
|
stroke: #0f3460;
|
||||||
|
stroke-opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .overview-data-polygon {
|
||||||
|
fill: rgba(233, 69, 96, 0.2);
|
||||||
|
stroke: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .overview-data-node {
|
||||||
|
fill: #e94560;
|
||||||
|
stroke: #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .overview-label {
|
||||||
|
fill: #a0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .overview-progress-bg {
|
||||||
|
background: #0f3460 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .overview-progress-fill {
|
||||||
|
background: linear-gradient(90deg, #e94560, #0f3460) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .wrapped-label,
|
||||||
|
.wrapped-theme-vhs .wrapped-body {
|
||||||
|
color: #a0a0a0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .wrapped-number {
|
||||||
|
color: #e94560 !important;
|
||||||
|
text-shadow:
|
||||||
|
-1px 0 rgba(0, 255, 247, 0.5),
|
||||||
|
1px 0 rgba(255, 0, 255, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(v, hi) in row"
|
v-for="(v, hi) in row"
|
||||||
:key="`${wi}-${hi}`"
|
:key="`${wi}-${hi}`"
|
||||||
class="aspect-square min-h-[10px] rounded-[2px] transition-transform duration-150 hover:scale-125 hover:z-10 relative"
|
class="heatmap-cell aspect-square min-h-[10px] rounded-[2px] transition-transform duration-150 hover:scale-125 hover:z-10 relative"
|
||||||
:style="{ backgroundColor: colorFor(v), transformOrigin: originFor(wi, hi) }"
|
:style="{ backgroundColor: colorFor(v), transformOrigin: originFor(wi, hi) }"
|
||||||
:title="tooltipFor(wi, hi, v)"
|
:title="tooltipFor(wi, hi, v)"
|
||||||
/>
|
/>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="wrapped-body">低</span>
|
<span class="wrapped-body">低</span>
|
||||||
<div class="flex items-center gap-[2px]">
|
<div class="flex items-center gap-[2px]">
|
||||||
<span v-for="i in 6" :key="i" class="w-4 h-2 rounded-[2px]" :style="{ backgroundColor: legendColor(i) }"></span>
|
<span v-for="i in 6" :key="i" class="heatmap-legend-cell w-4 h-2 rounded-[2px]" :style="{ backgroundColor: legendColor(i) }"></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="wrapped-body">高</span>
|
<span class="wrapped-body">高</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +58,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { heatColor, maxInMatrix, formatHourRange } from '~/utils/wrapped/heatmap'
|
import { themedHeatColor, maxInMatrix, formatHourRange } from '~/utils/wrapped/heatmap'
|
||||||
|
import { useWrappedTheme } from '~/composables/useWrappedTheme'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
weekdayLabels: { type: Array, default: () => ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
|
weekdayLabels: { type: Array, default: () => ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
|
||||||
@@ -67,6 +68,8 @@ const props = defineProps({
|
|||||||
totalMessages: { type: Number, default: 0 }
|
totalMessages: { type: Number, default: 0 }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { theme } = useWrappedTheme()
|
||||||
|
|
||||||
const matrixSafe = computed(() => {
|
const matrixSafe = computed(() => {
|
||||||
// Expect 7x24, but keep defensive to avoid UI crashes.
|
// Expect 7x24, but keep defensive to avoid UI crashes.
|
||||||
const m = Array.isArray(props.matrix) ? props.matrix : []
|
const m = Array.isArray(props.matrix) ? props.matrix : []
|
||||||
@@ -89,7 +92,7 @@ const timeLabels = computed(() => {
|
|||||||
return labels
|
return labels
|
||||||
})
|
})
|
||||||
|
|
||||||
const colorFor = (v) => heatColor(v, maxValue.value)
|
const colorFor = (v) => themedHeatColor(v, maxValue.value, theme.value)
|
||||||
|
|
||||||
const tooltipFor = (weekdayIndex, hour, v) => {
|
const tooltipFor = (weekdayIndex, hour, v) => {
|
||||||
const w = props.weekdayLabels?.[weekdayIndex] ?? `周${weekdayIndex + 1}`
|
const w = props.weekdayLabels?.[weekdayIndex] ?? `周${weekdayIndex + 1}`
|
||||||
@@ -100,7 +103,7 @@ const tooltipFor = (weekdayIndex, hour, v) => {
|
|||||||
|
|
||||||
const legendColor = (i) => {
|
const legendColor = (i) => {
|
||||||
const t = i / 6
|
const t = i / 6
|
||||||
return heatColor(Math.max(1, t * (maxValue.value || 1)), maxValue.value || 1)
|
return themedHeatColor(Math.max(1, t * (maxValue.value || 1)), maxValue.value || 1, theme.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const originFor = (weekdayIndex, hour) => {
|
const originFor = (weekdayIndex, hour) => {
|
||||||
@@ -111,3 +114,60 @@ const originFor = (weekdayIndex, hour) => {
|
|||||||
return `${x} ${y}`
|
return `${x} ${y}`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ========== Game Boy 主题 ========== */
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .heatmap-cell {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .wrapped-label,
|
||||||
|
.wrapped-theme-gameboy .wrapped-body {
|
||||||
|
color: #306230 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .wrapped-number {
|
||||||
|
color: #0f380f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-gameboy .heatmap-legend-cell {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== DOS 主题 ========== */
|
||||||
|
|
||||||
|
.wrapped-theme-dos .heatmap-cell {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: 0 0 2px rgba(51, 255, 51, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .wrapped-label,
|
||||||
|
.wrapped-theme-dos .wrapped-body {
|
||||||
|
color: #22aa22 !important;
|
||||||
|
text-shadow: 0 0 3px #22aa22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .wrapped-number {
|
||||||
|
color: #33ff33 !important;
|
||||||
|
text-shadow: 0 0 5px #33ff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-dos .heatmap-legend-cell {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== VHS 主题 ========== */
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .wrapped-label,
|
||||||
|
.wrapped-theme-vhs .wrapped-body {
|
||||||
|
color: #a0a0a0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapped-theme-vhs .wrapped-number {
|
||||||
|
color: #e94560 !important;
|
||||||
|
text-shadow:
|
||||||
|
-1px 0 rgba(0, 255, 247, 0.5),
|
||||||
|
1px 0 rgba(255, 0, 255, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -38,6 +38,41 @@ export const heatColor = (value, max) => {
|
|||||||
return `hsl(${hue.toFixed(1)} ${sat}% ${light.toFixed(1)}%)`
|
return `hsl(${hue.toFixed(1)} ${sat}% ${light.toFixed(1)}%)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme-aware heat color function
|
||||||
|
export const themedHeatColor = (value, max, theme) => {
|
||||||
|
const v = Number(value) || 0
|
||||||
|
const m = Number(max) || 0
|
||||||
|
const t = (v > 0 && m > 0) ? clamp01(Math.sqrt(v / m)) : 0
|
||||||
|
|
||||||
|
switch (theme) {
|
||||||
|
case 'gameboy': {
|
||||||
|
// Game Boy 4-color palette: #0f380f, #306230, #8bac0f, #9bbc0f
|
||||||
|
if (t === 0) return '#9bbc0f'
|
||||||
|
if (t < 0.33) return '#8bac0f'
|
||||||
|
if (t < 0.66) return '#306230'
|
||||||
|
return '#0f380f'
|
||||||
|
}
|
||||||
|
case 'dos': {
|
||||||
|
// DOS green phosphor: from dark to bright green
|
||||||
|
if (t === 0) return 'rgba(51, 255, 51, 0.1)'
|
||||||
|
const light = 20 + 60 * t
|
||||||
|
return `hsl(120 100% ${light.toFixed(1)}%)`
|
||||||
|
}
|
||||||
|
case 'vhs': {
|
||||||
|
// VHS: from dark blue to pink/magenta
|
||||||
|
if (t === 0) return 'rgba(15, 52, 96, 0.3)'
|
||||||
|
// Interpolate from #0f3460 (dark blue) to #e94560 (pink)
|
||||||
|
const r = Math.round(15 + (233 - 15) * t)
|
||||||
|
const g = Math.round(52 + (69 - 52) * t)
|
||||||
|
const b = Math.round(96 + (96 - 96) * t)
|
||||||
|
return `rgb(${r}, ${g}, ${b})`
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Modern (off) - use original heatColor
|
||||||
|
return heatColor(value, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const formatHourRange = (hour) => {
|
export const formatHourRange = (hour) => {
|
||||||
const h = Number(hour)
|
const h = Number(hour)
|
||||||
if (!Number.isFinite(h)) return ''
|
if (!Number.isFinite(h)) return ''
|
||||||
|
|||||||
Reference in New Issue
Block a user