feat: 个人最佳趋势改为折线图 + 底部最佳趋势日期 + 修复
- 趋势组件 trend-chart 新增 chartType(line/bar),line 模式用内联 SVG 渲染折线+渐变面积(无 canvas) - 记录页个人最佳趋势改用折线图,峰值信息移到图表下方对称高亮块显示最佳趋势产生日期 - 修复1: linePoints 漏存绝对坐标 y 导致运行时 toFixed 崩溃 - 修复2: 折线模式漏渲染数据点数值(valueText),现圆点上方显示且 0 值不显示
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user