← 返回文档索引

name: chart-js-patterns description: Chart.js v4 patterns for dual-axis bar charts, nested doughnut charts, and chartjs-plugin-datalabels — spacing, axis config, dataset ordering, and label positioning for the SietaData dashboard.


Chart.js v4 Patterns

CDN & Loading Strategy for China

⚠️ jsdelivr CDN 在中国被墙(2026-05-26 确认)

cdn.jsdelivr.net 在中国大陆访问不稳定/被墙。Chart.js 和 datalabels 从 jsdelivr 加载失败时,new Chart() 静默崩溃,无控制台错误,图表区域空白。

✅ 统一使用 cdnjs.cloudflare.com:

<!-- Chart.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<!-- datalabels — 同一个 CDN,同样稳定 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.js"></script>

⚠️ 不要用 jsdelivr:

<!-- ❌ cdn.jsdelivr.net — 中国频繁被墙,图表静默空白 -->
<script src="js/chart.umd.min.js"></script>
<script src="js/chartjs-plugin-datalabels.min.js"></script>

诊断方法: 浏览器 F12 → Console → 执行 Chart.getChart('c1')。返回 null 说明 new Chart() 静默失败。第一步换 CDN。

CDN & Loading Strategy for China

⚠️ jsdelivr 被墙 → 图表空白(2026-05-26 根因)

cdn.jsdelivr.net 在中国大陆频繁被墙/超时。当 Chart.js 从 jsdelivr 加载失败时,new Chart() 静默失败(不抛异常,canvas 空白,无 console 错误)。

✅ 使用 cdnjs.cloudflare.com(国内稳定):

<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.js"></script>

诊断方法: 浏览器控制台执行 Chart.getChart('canvasId') —— 返回 null 说明 new Chart() 静默失败。

Python f-string 生成 HTML 时的 JS 括号陷阱

当用 Python f-string 生成包含 Chart.js 配置的 HTML 时,JS 的嵌套对象大括号 {} 会和 Python f-string 的 {} 冲突:

# ❌ 错误——JS的 {font:{size:10}} 被 Python 解析
html = f'new Chart(ctx, {{type: "bar", options: {{scales: {{y: {{ticks: {{font: {{size: 10}}}}}}}}}}}})'

# ❌ 正确转义但极易出错——{{ → {, }} → }
html = f'new Chart(ctx, {{type: "bar", options: {{scales: {{y: {{ticks: {{font: {{size: 10}}}}}}}}}}}})'

# ✅ 推荐——数据放 JSON 标签,JS 读取,彻底避开括号地狱
html = '''<script id="chartData" type="application/json">''' + json.dumps(data) + '''</script>
<script>
var d = JSON.parse(document.getElementById("chartData").textContent);
new Chart(ctx, {type: "bar", data: d, options: {...}});
</script>'''

症状:图表静默不渲染,无 console 错误,Chart.getChart('c1') 返回 false。根因往往是 Python 生成 HTML 时括号转义错误导致 JS 语法错误。

当用 Python f-string 生成含 {} 的 JavaScript 代码时,{{ 转义为 {。但在深层嵌套对象中极易出错,错误静默(图表不渲染无报错)。

❌ 错误做法:

html = f'''<script>
new Chart(ctx, {{
  data: {{
    datasets: [{{ label: 'x', data: [1,2] }}]
  }}
}});
</script>'''

✅ 正确做法:数据与代码分离

# 数据放 <script type="application/json">
html += '<script id="storeData" type="application/json">' + json.dumps(data) + '</script>'

# 代码用独立 <script>,JS 的 JSON.parse() 读取——所有 {} 都是纯 JS
html += '''<script>
(function() {
  var data = JSON.parse(document.getElementById('storeData').textContent);
  new Chart(ctx, {
    data: {
      datasets: [{ label: 'x', data: data }]
    }
  });
})();
</script>'''

⚠️ CDN 选择(2026-05-26 教训): - cdn.jsdelivr.net — 在中国频繁被墙/阻断,new Chart() 静默失败,图表区域空白无任何报错 - cdnjs.cloudflare.com — ✅ 中国可访问,稳定可靠 - 所有静态 HTML 页面必须用 cdnjs.cloudflare.com ```html

`` - ⚠️chartjs-plugin-datalabels从 jsdelivr 加载失败时也会静默崩溃。如果不需要数据标签,干脆不加载此插件。 - 图表空白时第一步检查:换 CDN + 去掉 datalabels。 NodeferorDOMContentLoaded` needed — synchronous script tags guarantee load order.

Custom Data Labels (No Plugin Dependency)

When chartjs-plugin-datalabels CDN is unreliable, use a custom afterDraw plugin:

var labelPlugin = {
  id: 'customLabels',
  afterDraw: function(chart){
    var ctx = chart.ctx;
    chart.data.datasets.forEach(function(ds, i){
      var meta = chart.getDatasetMeta(i);
      meta.data.forEach(function(elem, j){
        var val = ds.data[j];
        if(val == null) return;
        ctx.save();
        ctx.font = 'bold 11px sans-serif';
        ctx.textAlign = 'left';
        ctx.textBaseline = 'middle';
        var txt = ds.label === '销售额' ? '¥' + val.toLocaleString() : val + '双';
        ctx.fillStyle = '#000';
        ctx.fillText(txt, elem.x + 4, elem.y);
        ctx.restore();
      });
    });
  }
};
// Pass as plugins option:
new Chart(c1, { ..., options: { ..., plugins: [labelPlugin] } });

Static HTML → Guaranteed Content Visibility

For critical pages (weekly reports, summaries), write ALL HTML as static markup. Only use JavaScript for Chart.js rendering. This ensures that even if chart creation crashes, the user still sees tables, KPIs, and text.

Data Source Consistency

When building weekly/daily reports, ensure all displayed numbers come from the SAME database query. Common pitfall: KPI from staff_sales (6 days) vs store chart from sales_ledger (3 days with store info) → KPI says ¥73,092 but chart bars add up to ¥24,079. Query all data points from one source, or acknowledge coverage in the header.

Dual-Axis Horizontal Bar Charts

Dataset config

// 销售额 on top axis (x1), 双数 on bottom axis (x)
// Legend order: 销售额 (left), 双数 (right)
datasets: [
  { label: '销售额', data: amts, yAxisID: 'y', xAxisID: 'x1', ... },
  { label: '双数', data: qtys, yAxisID: 'y', xAxisID: 'x', ... },
]

Scales config

scales: {
  x:  { position: 'bottom', title: { text: '双数' }, beginAtZero: true, max: dynamic },
  x1: { position: 'top',    title: { text: '销售额 (¥)' }, grid: { drawOnChartArea: false }, beginAtZero: true },
  y:  { grid: { display: false } }
}

Scale axis border (spine line)

Chart.js v4 uses scale.border for the axis spine line, not grid.drawBorder (v3 API, ignored in v4).

// Show a 2px black axis line on the y-scale (left edge of chart area)
y: {
  border: { display: true, color: '#000', width: 2 },
  grid: { drawOnChartArea: false },  // hides chart-area grid lines
  ticks: { font: { size: 11, weight: 'bold' } }
}
Property Type Default Description
border.display boolean true Show the axis spine line
border.color Color chart options Color of the spine line
border.width number 1 Width of the spine line in px
border.dash number[] [] Dash pattern for the spine line

Tip for horizontal bar charts (indexAxis: 'y'): The y-axis (category axis) is the vertical line on the left. Add grid: { drawOnChartArea: false } to hide horizontal category grid lines while showing the axis border.

Bar spacing for grouped bars

barPercentage: 1.0,      // each bar fills 100% of its allocated half
categoryPercentage: 0.78 // 78% of category slot, leaving ~4px gap between groups

Bar borders (black outline)

Add borderWidth + borderColor: '#000' to each bar dataset for a neo-brutalist outlined look. Typical widths: 1 (thin), 1.5, or 2 (bold).

// Apply to each dataset in the group
{
  borderColor: '#000',
  borderWidth: 2,    // 1 = thin, 1.5 = medium, 2 = bold
  barPercentage: 1.0 // see below for gap handling
}

⚠️ Border doubling on adjacent bars: When two bars in the same category touch, their shared-edge borders overlap (1+1=2px, or 2+2=4px):

┌──────────┬──────────┐
│          ║          │  ← ║ = 4px thick (2+2)
│  销售额  ║   双数   │
│          ║          │
└──────────┴──────────┘

Fix — two options:

Option Config Effect
A) Reduce barPercentage barPercentage: 0.96 + borderWidth: 2 ~1px gap between bars, borders don't touch. Both bars get full 4-sided borders.
B) Accept doubled border barPercentage: 1.0 + borderWidth: 2 Bars touch, shared edge is 4px. Looks intentional as a bold dividing line. Simpler, no gap needed.

Why barPercentage works: With 2 bars per category, each bar's slot = 50% of category height. barPercentage: 0.96 = each bar uses 96% of its slot, leaving 4% gap ≈ 1px.

Must apply barPercentage to ALL datasets in the group consistently for even spacing.

Dynamic axis max

Set bottom axis max to 1.5× the actual max value so the top-axis bars always appear longer:

const maxQty = Math.max(...stores.map(s => s.qty)) * 1.5;
scales: { x: { max: maxQty } }

Nested Doughnut Charts

⚠️ CDN Reliability for chartjs-plugin-datalabels (root cause of silent crashes)

The 3-dataset nested doughnut (weight:2/1/7 + cutout:'2%') DOES work on static pages when ChartDataLabels is loaded correctly. The real failure mode is CDN unreliability:

✅ Fix: Host chartjs-plugin-datalabels.min.js locally (not CDN):

# Download once to nginx-served directory
curl -sL "js/chartjs-plugin-datalabels.min.js" \
  -o /tmp/chartjs-plugin-datalabels.min.js
sudo cp /tmp/chartjs-plugin-datalabels.min.js /var/www/sieta/sietadata/

# Reference locally instead of CDN
# <script src="/sietadata/chartjs-plugin-datalabels.min.js"></script>

The 3-dataset nested doughnut pattern below works reliably with a local datalabels plugin. No need to downgrade to single-dataset doughnut.

⚠️ Dataset order (critical pitfall)

Chart.js v4 renders datasets from OUTER (index 0) to INNER (index last):

dataset[0] → outermost ring
dataset[1] → middle ring
dataset[2] → innermost ring

Dataset order (critical pitfall)

dataset[0] → outermost ring dataset[1] → middle ring dataset[2] → innermost ring


### Weight controls radial proportion

```js
datasets: [
  { label: '双数', data: qtys,  weight: 2 },  // outermost, thin (10%)
  { label: '',     data: gap,   weight: 1 },  // invisible spacer, ~8px
  { label: '销售额', data: amts, weight: 7 },  // innermost, thick (80%)
]

Colors for segment charts

// Neo-brutalist palette for top-4 brands + 其他
const P = ['#FF6B6B', '#FFD93D', '#C4B5FD', '#4361ee', '#94A3B8'];
const colors = P.slice(0, numSegments);

chartjs-plugin-datalabels v2

⚠️ position is NOT a valid option (critical pitfall)

The v2 API uses anchor + align, NOT position:

Option Purpose Values
anchor Where on the element to anchor 'center' (default), 'start', 'end'
align Where relative to anchor 'center' (default), 'start', 'end', 'right', 'bottom', 'left', 'top'
offset Pixels from anchor number, ignored when align is 'center'

Bar chart: value labels outside bars (right side)

datalabels: {
  anchor: 'end',
  align: 'end',
  offset: 4,
  color: '#000',
  font: { weight: 'bold', size: 13 },
  formatter: v => '¥' + v.toLocaleString()
}

Bar chart: value labels inside bars, anchored at start (贴Y轴)

// 双数标注 — 柱子左端,贴Y轴(文字在柱内向右延伸)
datalabels: {
  anchor: 'start',   // 锚定柱子左边缘(靠近Y轴)
  align: 'end',      // 文字从锚定点向右延伸(柱内)
  offset: 2,
  color: '#000',
  font: { weight: 'bold', size: 12 },
  formatter: v => v + '双'
}

anchor/align 速查(水平柱状图 indexAxis:'y'):

效果 anchor align
柱右侧外 'end' 'end'
柱内右侧(溢出安全) 'end' 'start'
柱左端贴Y轴 'start' 'end'
柱内居中 'center' 'center'

anchor/align 速查(水平柱状图 indexAxis:'y'):

效果 anchor align
柱右侧外 'end' 'end'
柱内右侧(溢出安全) 'end' 'start'
柱左端贴Y轴 'start' 'end'
柱内居中 'center' 'center'

Overflow label positioning (dynamic inside/outside)

For horizontal bar charts with wide value ranges: the longest bar's label may overflow the canvas right edge. Fix by using function-based align + textAlign that switches to inside-right positioning only for the max-value bar:

// Compute max value BEFORE chart constructor
const amounts = data.stores.map(s => s.amt);
const maxAmt = Math.max(...amounts);

datalabels: {
  anchor: 'end',
  // Inside bar for max bar (start = inside), outside for others (end = outside)
  align: function(ctx) {
    // ⚠️ Use ctx.dataset.data[ctx.dataIndex], NOT ctx.dataItem
    // ctx.dataItem may be undefined in some chartjs-plugin-datalabels versions
    var v = ctx.dataset.data[ctx.dataIndex];
    return v === maxAmt ? 'start' : 'end';
  },
  offset: 4,
  color: function(ctx) {
    var v = ctx.dataset.data[ctx.dataIndex];
    return v === maxAmt ? '#fff' : '#000';  // white text inside red bar
  },
  font: { weight: 'bold', size: 13 },
  // Right-aligned inside the bar for max bar
  textAlign: function(ctx) {
    var v = ctx.dataset.data[ctx.dataIndex];
    return v === maxAmt ? 'right' : 'left';
  },
  formatter: v => '¥' + v.toLocaleString()
}

How it works for horizontal bars (indexAxis: 'y'): - anchor: 'end' → anchor at the bar's right end - align: 'end' → label extends rightward from anchor (= outside right) - align: 'start' → label extends leftward from anchor (= inside bar) - textAlign: 'right' + align: 'start' → text right-justified at the bar's right edge

Only apply to the sales-amount dataset (the one with the wider values). The qty dataset typically has smaller numbers and doesn't overflow.

Bonus: grace on the scale. Add grace: '10%' to the top x-axis to give ALL labels extra breathing room. This works even without the function-based align trick:

x1: {
  position: 'top',
  beginAtZero: true,
  grace: '10%'   // ← extends axis max by 10%
}

⚠️ ctx.dataItem vs ctx.dataset.data[ctx.dataIndex]

In chartjs-plugin-datalabels v2, both should work, but ctx.dataItem has been unreliable in practice. Always use ctx.dataset.data[ctx.dataIndex] for accessing the current data value inside datalabels functions. When ctx.dataItem returns undefined, function-based align/textAlign/color silently fall through to their else-branch, potentially breaking overflow detection.

Doughnut: labels on outer ring (inside ring surface)

datalabels: {
  anchor: 'end',    // outer edge of ring
  align: 'start',   // inside the ring
  offset: 2,
  font: { size: 12 }
}

Doughnut: labels centered on inner pie

datalabels: {
  font: { size: 15, weight: 'bold' },
  textAlign: 'center',
  offset: 0
}

⚠️ Registration (CRITICAL — labels silently fail if omitted)

The datalabels plugin must be explicitly registered with Chart.register(). If you forget this line, ALL data labels silently disappear — no console errors, no warnings, the chart renders fine but without any numbers.

// ⚠️ MUST be called before any chart is created
if (typeof ChartDataLabels !== 'undefined') Chart.register(ChartDataLabels);

Common mistake: When rewriting a page's HTML/JS code from scratch, it's easy to copy the chart configuration but forget the one-line registration at the top. The labels then stop working across the entire page with zero feedback. Add this line first — before any chart data or options.

CDN loading check: After switching to cdnjs.cloudflare.com, verify ChartDataLabels is defined:

console.log('ChartDataLabels loaded:', typeof ChartDataLabels !== 'undefined');

⚠️ ctx.dataItem vs ctx.dataset.data[ctx.dataIndex]

In chartjs-plugin-datalabels v2, both should work, but ctx.dataItem has been unreliable in practice. Always use ctx.dataset.data[ctx.dataIndex] for accessing the current data value inside datalabels functions. When ctx.dataItem returns undefined, function-based align/textAlign/color silently fall through to their else-branch, potentially breaking overflow detection.

Set datalabels: { display: false } on datasets that should not show labels.


Neo-brutalist Color Palette for Charts

Usage Color Hex
金额 bars / primary accent Hot Red #FF6B6B
双数 bars / background Soft Violet #C4B5FD
Brand 金额 / secondary Vivid Yellow #FFD93D
Brand 双数 Soft Violet #C4B5FD
Background Cream #FFFDF5
Text / borders Pure Black #000000
其他 (pie "other" segment) Slate Gray #94A3B8

Avoid: Black as main bar color (user complaint: "黑色做主色太无厘头了").


Interaction: Disable on specific chart

// For bar charts: disable datalabels globally via options
options: {
  plugins: {
    datalabels: { display: false } // ← DON'T do this, it overrides per-dataset
  }
}
// Instead: disable per-dataset
datasets: [{ datalabels: { display: false } }]

CSS Class Collision Pitfall (2026-05-26)

当一个页面同时包含 Chart.js 图表卡片和商品图片卡片时,不要共用 .cd 类名

商品卡片的 CSS(display:flex, border-bottom, min-height)会覆盖图表卡片的 CSS(background, border, box-shadow, padding),导致图表渲染在窄 flex 容器内,宽度被限制。

症状: 图表区域明显变窄,Chart.getChart('c1') 可能仍为 true(图表存在但容器变形)。

✅ 修复: 图表卡片用 .cd,商品卡片用 .ci(或任意不同类名)。修改后验证图表全宽渲染。

toISOString() 时区陷阱(JS 日期,影响图表数据范围)

new Date(2026, 4, 1).toISOString().slice(0,10) 返回 UTC 时间。中国时区(+8)下,5月1日 00:00 的 UTC 是 4月30日 16:00,截取得 2026-04-30。导致日期选择器(本月/本周按钮)日期错位一个月。

✅ 修复:用本地时间方法格式化

const d = new Date(2026, 4, 1);
const dateStr = d.getFullYear() + '-' +
  String(d.getMonth() + 1).padStart(2, '0') + '-' +
  String(d.getDate()).padStart(2, '0');
// dateStr = "2026-05-01" ✅

See the Neo-brutalism design system in the conversation for full styling spec: thick borders (border-4), hard shadows (box-shadow: Npx Npx 0 #000), bold typography, and cream background.


Python f-string → HTML 含 JS 时的括号地狱

用 Python f-string 生成含 Chart.js 代码的 HTML 时,JS 的 { } 需要加倍写成 {{ }}。深层嵌套对象(如 scales:{x:{title:{text:'双数'}}})极易少写或多写括号。

错误表现: 页面加载后图表区域空白,控制台无报错,Chart.getChart('c1') 返回 null

推荐方案:数据与代码分离

# ✅ 数据放在 <script type="application/json"> 标签
html += '<script id="d" type="application/json">' + json.dumps(data) + '</script>'

# ✅ JS 用 JSON.parse() 读取数据,不用 f-string 嵌入
html += '<script>(function(){ var d = JSON.parse(document.getElementById("d").textContent); new Chart(...); })();</script>'

诊断:new Chart() 静默失败时,浏览器控制台手动执行简化版 new Chart(ctx, {type:'bar', data:{labels:['a'], datasets:[{data:[1]}]}}) 确认 Chart.js 可用,然后检查原始代码中是否有括号不匹配。

When the user says "和 X 页面一模一样的效果", do NOT improvise or redesign. Clone the working reference page structure exactly and only swap the data.

Steps: 1. Read the reference page COMPLETELY (e.g. sales_analysis_mobile.html) 2. Copy its ENTIRE CSS block verbatim — no additions, no removals 3. Copy its ENTIRE chart configs verbatim — same dataset order, weights, options 4. Copy its KPI computation logic verbatim 5. Only changes: replace fetch() calls with hardcoded data; change page title 6. Verify with browser console — zero JS errors is the acceptance criteria

Pitfall: The most common failure is "I'll rebuild from memory" — this produces a structurally different page that silently breaks chart rendering. When debugging chart failures on a cloned page, always diff against the reference to find structural differences.

CSS Class Collision Pitfall (2026-05-26)

When a page has both Chart.js chart cards AND product image cards, do NOT use the same CSS class for both. The product card CSS (display:flex, border-bottom, min-height) will override the chart card CSS (background, border, box-shadow, padding), causing charts to render in a narrow flex container.

✅ Fix: Chart cards use .cd, product cards use .ci (or any distinct class name). After changing the class, verify with Chart.getChart('c1') that charts still render correctly.