feat: 个人最佳趋势改为折线图 + 底部最佳趋势日期 + 修复

- 趋势组件 trend-chart 新增 chartType(line/bar),line 模式用内联 SVG 渲染折线+渐变面积(无 canvas)
- 记录页个人最佳趋势改用折线图,峰值信息移到图表下方对称高亮块显示最佳趋势产生日期
- 修复1: linePoints 漏存绝对坐标 y 导致运行时 toFixed 崩溃
- 修复2: 折线模式漏渲染数据点数值(valueText),现圆点上方显示且 0 值不显示
This commit is contained in:
2026-08-04 15:46:18 +08:00
parent 187c426243
commit ca1793b4c0
8 changed files with 222 additions and 31 deletions
+69 -12
View File
@@ -1,38 +1,53 @@
const { getBarPercent } = require('../../utils/util')
/**
* Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's
* rendering approach for consistency.
* Trend chart pure CSS bar mode + optional SVG line mode (no canvas),
* matching the calendar-heatmap's rendering approach for consistency.
*
* chartData: [{ label, value, valueText, highlight }]
* - label: bottom caption (e.g. "一" / "7月")
* - value: numeric magnitude (seconds); drives bar height
* - value: numeric magnitude (seconds); drives bar height / line y
* - valueText: formatted string shown above the bar (e.g. "1分30秒")
* - highlight: truthy -> theme glow + bold label (today / this month)
*
* Bar height is strictly proportional to value/max. Zero values render a
* faint stub so empty dates remain visible without distorting the data.
* chartType: 'bar' (default) | 'line'
* - bar: CSS columns, height strictly proportional to value/max.
* - line: inline SVG (data URI) polyline + translucent area, rendered in an
* <image>; dots + axis labels are real WXML elements positioned by
* percentage so they stay crisp and theme-colored (SVG inside <image>
* cannot read page CSS variables). The viewBox aspect is matched to
* the container's rpx aspect (widthFix) so the stroke stays uniform
* — no non-uniform horizontal/vertical stretch.
*
* lineColor: hex used for the SVG stroke + area fill. Must be passed by the
* page because SVG inside <image> cannot read page CSS variables.
*/
const VB_W = 640
Component({
properties: {
chartData: { type: Array, value: [] },
variant: { type: String, value: 'total' }
variant: { type: String, value: 'total' },
chartType: { type: String, value: 'bar' },
lineColor: { type: String, value: '#FF6B35' }
},
data: {
bars: []
bars: [],
linePoints: [],
lineSvg: ''
},
observers: {
'chartData'(chartData) {
this._compute(chartData)
'chartData, chartType, lineColor'(chartData, chartType, lineColor) {
this._compute(chartData, chartType, lineColor)
}
},
lifetimes: {
ready() {
this._compute(this.properties.chartData)
this._compute(this.properties.chartData, this.properties.chartType, this.properties.lineColor)
}
},
methods: {
_compute(chartData) {
_compute(chartData, chartType, lineColor) {
const arr = Array.isArray(chartData) ? chartData : []
const max = arr.reduce((m, d) => Math.max(m, Number(d && d.value) || 0), 0)
const bars = arr.map((d) => {
@@ -45,7 +60,49 @@ Component({
percent: getBarPercent(v, max)
}
})
this.setData({ bars })
const N = arr.length
let linePoints = []
let lineSvg = ''
if (N > 0 && chartType === 'line') {
const lineH = this.properties.variant === 'peak' ? 170 : 200
const topPad = 16
const bottomPad = 12
const plot = lineH - topPad - bottomPad
linePoints = bars.map((b, i) => {
const x = (i + 0.5) * (VB_W / N)
const y = topPad + (1 - b.percent / 100) * plot
return {
x,
y,
xPercent: (x / VB_W) * 100,
yPercent: (y / lineH) * 100,
label: b.label,
valueText: b.valueText,
value: b.value,
highlight: b.highlight
}
})
lineSvg = this._buildLineSvg(linePoints, VB_W, lineH, lineColor || '#FF6B35')
}
this.setData({ bars, linePoints, lineSvg })
},
_buildLineSvg(points, W, H, color) {
const valid = (points || []).filter(p => p && typeof p.x === 'number' && typeof p.y === 'number')
if (valid.length === 0) return ''
const pts = valid.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
const first = valid[0]
const last = valid[valid.length - 1]
let area = `M ${first.x.toFixed(2)},${H} `
valid.forEach((p) => { area += `L ${p.x.toFixed(2)},${p.y.toFixed(2)} ` })
area += `L ${last.x.toFixed(2)},${H} Z`
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">` +
`<path d="${area}" fill="${color}" fill-opacity="0.16"/>` +
`<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>` +
`</svg>`
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
}
}
})
+25 -9
View File
@@ -1,12 +1,28 @@
<view class="trend-chart {{variant === 'peak' ? 'peak' : ''}}">
<view class="trend-bars">
<view class="trend-bar-col" wx:for="{{bars}}" wx:key="label">
<view class="trend-bar {{item.value === 0 ? 'zero' : ''}} {{item.highlight ? 'highlight' : ''}}" style="height: {{item.percent}}%;">
<text class="trend-bar-value" wx:if="{{item.value > 0}}">{{item.valueText}}</text>
<view class="trend-chart {{variant === 'peak' ? 'peak' : ''}} {{chartType === 'line' ? 'line' : ''}}">
<!-- Bar mode -->
<block wx:if="{{chartType !== 'line'}}">
<view class="trend-bars">
<view class="trend-bar-col" wx:for="{{bars}}" wx:key="label">
<view class="trend-bar {{item.value === 0 ? 'zero' : ''}} {{item.highlight ? 'highlight' : ''}}" style="height: {{item.percent}}%;">
<text class="trend-bar-value" wx:if="{{item.value > 0}}">{{item.valueText}}</text>
</view>
</view>
</view>
</view>
<view class="trend-labels">
<text class="trend-bar-label {{item.highlight ? 'highlight' : ''}}" wx:for="{{bars}}" wx:key="label">{{item.label}}</text>
</view>
<view class="trend-labels">
<text class="trend-bar-label {{item.highlight ? 'highlight' : ''}}" wx:for="{{bars}}" wx:key="label">{{item.label}}</text>
</view>
</block>
<!-- Line mode -->
<block wx:else>
<view class="trend-line">
<image class="trend-line-img" src="{{lineSvg}}" mode="widthFix"></image>
<view wx:for="{{linePoints}}" wx:key="label" class="trend-line-dot {{item.highlight ? 'highlight' : ''}}" style="left: {{item.xPercent}}%; top: {{item.yPercent}}%;">
<text wx:if="{{item.value > 0}}" class="trend-line-value">{{item.valueText}}</text>
</view>
</view>
<view class="trend-line-labels">
<text wx:for="{{linePoints}}" wx:key="label" class="trend-line-label {{item.highlight ? 'highlight' : ''}}" style="left: {{item.xPercent}}%;">{{item.label}}</text>
</view>
</block>
</view>
+77
View File
@@ -92,3 +92,80 @@
color: var(--primary);
font-weight: 600;
}
/* ---- Line mode ---- */
.trend-chart.line {
/* extra top room so the value caption above the highest dot isn't clipped */
padding-top: 30rpx;
}
.trend-line {
position: relative;
width: 100%;
}
.trend-line-img {
display: block;
width: 100%;
}
.trend-line-dot {
position: absolute;
width: 16rpx;
height: 16rpx;
margin-left: -8rpx;
margin-top: -8rpx;
border-radius: 50%;
background: var(--primary);
border: 3rpx solid var(--card-bg, #FFFFFF);
box-sizing: border-box;
}
.trend-chart.peak .trend-line-dot {
background: var(--primary);
}
.trend-line-dot.highlight {
width: 20rpx;
height: 20rpx;
margin-left: -10rpx;
margin-top: -10rpx;
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.6);
}
/* value caption floats above the dot so it never eats into the line */
.trend-line-value {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 6rpx;
font-size: 16rpx;
color: var(--text-secondary);
line-height: 1;
white-space: nowrap;
}
.trend-line-dot.highlight .trend-line-value {
color: var(--primary);
font-weight: 600;
}
.trend-line-labels {
position: relative;
height: 28rpx;
margin-top: 10rpx;
}
.trend-line-label {
position: absolute;
transform: translateX(-50%);
font-size: 20rpx;
color: var(--text-secondary);
white-space: nowrap;
}
.trend-line-label.highlight {
color: var(--primary);
font-weight: 600;
}