不够无敌炸裂的更新

This commit is contained in:
cc
2026-02-08 21:27:25 +08:00
parent 2389aaf314
commit e28ef9b783
13 changed files with 958 additions and 529 deletions

View File

@@ -0,0 +1,142 @@
// Shared styles for Report components (Heatmap, WordCloud)
// --- Heatmap ---
.heatmap-wrapper {
margin-top: 24px;
width: 100%;
}
.heatmap-header {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
margin-bottom: 6px;
color: var(--ar-text-sub); // Assumes --ar-text-sub is defined in parent context or globally
font-size: 10px;
}
.time-labels {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
span {
text-align: center;
}
}
.heatmap {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
}
.heatmap-week-col {
display: grid;
grid-template-rows: repeat(7, 1fr);
gap: 3px;
font-size: 10px;
color: var(--ar-text-sub);
}
.week-label {
display: flex;
align-items: center;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
}
.h-cell {
aspect-ratio: 1;
border-radius: 2px;
min-height: 10px;
transition: transform 0.15s;
&:hover {
transform: scale(1.3);
z-index: 1;
}
}
// --- Word Cloud ---
.word-cloud-wrapper {
margin: 24px auto 0;
padding: 0;
max-width: 520px;
display: flex;
justify-content: center;
--cloud-scale: clamp(0.72, 80vw / 520, 1);
}
.word-cloud-inner {
position: relative;
width: 520px;
height: 520px;
margin: 0;
border-radius: 50%;
transform: scale(var(--cloud-scale));
transform-origin: center;
&::before {
content: "";
position: absolute;
inset: -6%;
background:
radial-gradient(circle at 35% 45%, color-mix(in srgb, var(--primary, #07C160) 12%, transparent), transparent 55%),
radial-gradient(circle at 65% 50%, color-mix(in srgb, var(--accent, #F2AA00) 10%, transparent), transparent 58%),
radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%);
filter: blur(18px);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
}
.word-tag {
display: inline-block;
padding: 0;
background: transparent;
border-radius: 0;
border: none;
line-height: 1.2;
white-space: nowrap;
transition: transform 0.2s ease, color 0.2s ease;
cursor: default;
color: var(--ar-text-main);
font-weight: 600;
opacity: 0;
animation: wordPopIn 0.55s ease forwards;
position: absolute;
z-index: 1;
transform: translate(-50%, -50%) scale(0.8);
&:hover {
transform: translate(-50%, -50%) scale(1.08);
color: var(--ar-primary);
z-index: 2;
}
}
@keyframes wordPopIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
100% {
opacity: var(--final-opacity, 1);
transform: translate(-50%, -50%) scale(1);
}
}
.word-cloud-note {
margin-top: 24px;
font-size: 14px !important;
color: var(--ar-text-sub) !important;
text-align: center;
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportHeatmapProps {
data: number[][]
}
const ReportHeatmap: React.FC<ReportHeatmapProps> = ({ data }) => {
if (!data || data.length === 0) return null
const maxHeat = Math.max(...data.flat())
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return (
<div className="heatmap-wrapper">
<div className="heatmap-header">
<div></div>
<div className="time-labels">
{[0, 6, 12, 18].map(h => (
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
))}
</div>
</div>
<div className="heatmap">
<div className="heatmap-week-col">
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
</div>
<div className="heatmap-grid">
{data.map((row, wi) =>
row.map((val, hi) => {
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
return (
<div
key={`${wi}-${hi}`}
className="h-cell"
style={{
backgroundColor: 'var(--primary)',
opacity: alpha
}}
title={`${weekLabels[wi]} ${hi}:00 - ${val}`}
/>
)
})
)}
</div>
</div>
</div>
)
}
export default ReportHeatmap

View File

@@ -0,0 +1,113 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportWordCloudProps {
words: { phrase: string; count: number }[]
}
const ReportWordCloud: React.FC<ReportWordCloudProps> = ({ words }) => {
if (!words || words.length === 0) return null
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
// 使用确定性随机数生成器
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
// 计算词云位置
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt(dx * dx + dy * dy)
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
const pad = 1.8
for (const p of placedItems) {
if ((x - halfW - pad) < (p.x + p.w / 2) &&
(x + halfW + pad) > (p.x - p.w / 2) &&
(y - halfH - pad) < (p.y + p.h / 2) &&
(y + halfH + pad) > (p.y - p.h / 2)) {
return false
}
}
return true
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
// 计算词语宽度
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
// 寻找位置
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as React.CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
export default ReportWordCloud