/** * chartkit/bar —— 垂直分组条形图(零依赖 SVG) * * 输出一串 (按序列分组并排)+ 坐标轴 + 可选网格 + 图例 + 数据标签。 * 全部用逻辑坐标(viewBox),实际尺寸由外层容器缩放,矢量清晰。 */ import { escapeXml, fmt, niceMax, seriesColor, truncate, legendStartX } from './core' import type { ChartModel } from './types' export function renderBar(model: ChartModel): string { const opt = model.options ?? {} const W = opt.width ?? 480 const H = opt.height ?? 320 const title = opt.title const showAxis = opt.showAxis ?? true const showGrid = opt.showGrid ?? true const showLegend = opt.showLegend ?? model.series.length > 1 const valueLabel = opt.valueLabel ?? false const legendH = showLegend ? 22 : 0 const titleH = title ? 22 : 0 const mTop = 10 + titleH const mRight = 16 const mBottom = (showAxis ? 38 : 22) + legendH const mLeft = showAxis ? 44 : 16 const px0 = mLeft const py0 = mTop const px1 = W - mRight const py1 = H - mBottom const plotW = Math.max(1, px1 - px0) const plotH = Math.max(1, py1 - py0) const cats = model.categories.length ? model.categories : (model.series[0]?.data ?? []).map((_, i) => String(i + 1)) const n = cats.length const allVals: number[] = [] for (const s of model.series) for (const v of s.data) if (isFinite(v)) allVals.push(v) if (model.series.length === 0 || n === 0 || allVals.length === 0) { return placeholder(W, H, '暂无数据') } const yMax = niceMax(Math.max(0, ...allVals)) const band = plotW / Math.max(1, n) const inner = band * 0.74 const sCount = model.series.length const barW = inner / Math.max(1, sCount) const parts: string[] = [] parts.push(``) if (title) { parts.push(`${escapeXml(title)}`) } // 网格 + y 轴刻度 if (showAxis) { const ticks = 4 for (let i = 0; i <= ticks; i++) { const v = (yMax / ticks) * i const y = py1 - (v / yMax) * plotH if (showGrid && i > 0) { parts.push(``) } parts.push(`${fmt(v)}`) } // 基线 parts.push(``) } // 柱体 for (let c = 0; c < n; c++) { const cx = px0 + band * (c + 0.5) const groupX0 = cx - inner / 2 for (let s = 0; s < sCount; s++) { const val = model.series[s]!.data[c] ?? 0 if (!isFinite(val)) continue const h = (Math.max(0, val) / yMax) * plotH const x = groupX0 + s * barW const y = py1 - h const color = seriesColor(model.series[s]!, s, opt) parts.push(``) if (valueLabel && h > 0) { parts.push(`${fmt(val)}`) } } // x 轴标签:始终以类目为基准居中于对应条带正下方,并与图表留一点间距 if (showAxis) { parts.push(`${escapeXml(truncate(String(cats[c] ?? '')))}`) } } // 图例 if (showLegend) { const align = opt.labelAlign ?? 'center' let totalW = 0 for (let s = 0; s < sCount; s++) { const name = model.series[s]!.name || `系列${s + 1}` totalW += 16 + name.length * 12 + 18 } const startX = legendStartX(align, W, totalW, px0) let lx = startX let ly = py1 + (showAxis ? 30 : 16) for (let s = 0; s < sCount; s++) { const name = model.series[s]!.name || `系列${s + 1}` const color = seriesColor(model.series[s]!, s, opt) const itemW = 16 + name.length * 12 + 18 if (lx + itemW > W - 8 && lx > startX) { lx = startX ly += 16 } parts.push(``) parts.push(`${escapeXml(truncate(name, 10))}`) lx += itemW } } parts.push('') return parts.join('') } function placeholder(W: number, H: number, text: string): string { return `${escapeXml(text)}` }