@fui-org/fui-cli 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/dist/fui.js +160 -43
- package/package.json +4 -2
- package/skills/fui/SKILL.md +131 -66
- package/skills/fui-skill/README.md +112 -0
- package/skills/fui-skill/SKILL.md +269 -0
- package/skills/fui-skill/assets/projectdefaultstyle.css +518 -0
- package/skills/fui-skill/design-md/airbnb/DESIGN.md +545 -0
- package/skills/fui-skill/design-md/airbnb/README.md +5 -0
- package/skills/fui-skill/design-md/airtable/DESIGN.md +554 -0
- package/skills/fui-skill/design-md/airtable/README.md +5 -0
- package/skills/fui-skill/design-md/apple/DESIGN.md +562 -0
- package/skills/fui-skill/design-md/apple/README.md +5 -0
- package/skills/fui-skill/design-md/asu/DESIGN.md +179 -0
- package/skills/fui-skill/design-md/asu/README.md +107 -0
- package/skills/fui-skill/design-md/binance/DESIGN.md +634 -0
- package/skills/fui-skill/design-md/binance/README.md +5 -0
- package/skills/fui-skill/design-md/bmw/DESIGN.md +544 -0
- package/skills/fui-skill/design-md/bmw/README.md +5 -0
- package/skills/fui-skill/design-md/bmw-m/DESIGN.md +503 -0
- package/skills/fui-skill/design-md/bmw-m/README.md +5 -0
- package/skills/fui-skill/design-md/bugatti/DESIGN.md +454 -0
- package/skills/fui-skill/design-md/bugatti/README.md +5 -0
- package/skills/fui-skill/design-md/cal/DESIGN.md +542 -0
- package/skills/fui-skill/design-md/cal/README.md +5 -0
- package/skills/fui-skill/design-md/claude/DESIGN.md +589 -0
- package/skills/fui-skill/design-md/claude/README.md +5 -0
- package/skills/fui-skill/design-md/clay/DESIGN.md +541 -0
- package/skills/fui-skill/design-md/clay/README.md +5 -0
- package/skills/fui-skill/design-md/clickhouse/DESIGN.md +544 -0
- package/skills/fui-skill/design-md/clickhouse/README.md +5 -0
- package/skills/fui-skill/design-md/cohere/DESIGN.md +451 -0
- package/skills/fui-skill/design-md/cohere/README.md +5 -0
- package/skills/fui-skill/design-md/coinbase/DESIGN.md +570 -0
- package/skills/fui-skill/design-md/coinbase/README.md +5 -0
- package/skills/fui-skill/design-md/composio/DESIGN.md +506 -0
- package/skills/fui-skill/design-md/composio/README.md +5 -0
- package/skills/fui-skill/design-md/cursor/DESIGN.md +537 -0
- package/skills/fui-skill/design-md/cursor/README.md +5 -0
- package/skills/fui-skill/design-md/elevenlabs/DESIGN.md +504 -0
- package/skills/fui-skill/design-md/elevenlabs/README.md +5 -0
- package/skills/fui-skill/design-md/expo/DESIGN.md +526 -0
- package/skills/fui-skill/design-md/expo/README.md +5 -0
- package/skills/fui-skill/design-md/ferrari/DESIGN.md +531 -0
- package/skills/fui-skill/design-md/ferrari/README.md +5 -0
- package/skills/fui-skill/design-md/figma/DESIGN.md +578 -0
- package/skills/fui-skill/design-md/figma/README.md +5 -0
- package/skills/fui-skill/design-md/framer/DESIGN.md +544 -0
- package/skills/fui-skill/design-md/framer/README.md +5 -0
- package/skills/fui-skill/design-md/fui/DESIGN.md +532 -0
- package/skills/fui-skill/design-md/hashicorp/DESIGN.md +575 -0
- package/skills/fui-skill/design-md/hashicorp/README.md +5 -0
- package/skills/fui-skill/design-md/ibm/DESIGN.md +550 -0
- package/skills/fui-skill/design-md/ibm/README.md +5 -0
- package/skills/fui-skill/design-md/intercom/DESIGN.md +546 -0
- package/skills/fui-skill/design-md/intercom/README.md +5 -0
- package/skills/fui-skill/design-md/kraken/DESIGN.md +125 -0
- package/skills/fui-skill/design-md/kraken/README.md +5 -0
- package/skills/fui-skill/design-md/lamborghini/DESIGN.md +288 -0
- package/skills/fui-skill/design-md/lamborghini/README.md +5 -0
- package/skills/fui-skill/design-md/linear.app/DESIGN.md +548 -0
- package/skills/fui-skill/design-md/linear.app/README.md +5 -0
- package/skills/fui-skill/design-md/lovable/DESIGN.md +298 -0
- package/skills/fui-skill/design-md/lovable/README.md +5 -0
- package/skills/fui-skill/design-md/mastercard/DESIGN.md +365 -0
- package/skills/fui-skill/design-md/mastercard/README.md +5 -0
- package/skills/fui-skill/design-md/meta/DESIGN.md +683 -0
- package/skills/fui-skill/design-md/meta/README.md +5 -0
- package/skills/fui-skill/design-md/minimax/DESIGN.md +746 -0
- package/skills/fui-skill/design-md/minimax/README.md +5 -0
- package/skills/fui-skill/design-md/mintlify/DESIGN.md +852 -0
- package/skills/fui-skill/design-md/mintlify/README.md +5 -0
- package/skills/fui-skill/design-md/miro/DESIGN.md +825 -0
- package/skills/fui-skill/design-md/miro/README.md +5 -0
- package/skills/fui-skill/design-md/mistral.ai/DESIGN.md +773 -0
- package/skills/fui-skill/design-md/mistral.ai/README.md +5 -0
- package/skills/fui-skill/design-md/mongodb/DESIGN.md +767 -0
- package/skills/fui-skill/design-md/mongodb/README.md +5 -0
- package/skills/fui-skill/design-md/nike/DESIGN.md +575 -0
- package/skills/fui-skill/design-md/nike/README.md +5 -0
- package/skills/fui-skill/design-md/notion/DESIGN.md +821 -0
- package/skills/fui-skill/design-md/notion/README.md +5 -0
- package/skills/fui-skill/design-md/nvidia/DESIGN.md +640 -0
- package/skills/fui-skill/design-md/nvidia/README.md +5 -0
- package/skills/fui-skill/design-md/ollama/DESIGN.md +539 -0
- package/skills/fui-skill/design-md/ollama/README.md +5 -0
- package/skills/fui-skill/design-md/opencode.ai/DESIGN.md +521 -0
- package/skills/fui-skill/design-md/opencode.ai/README.md +5 -0
- package/skills/fui-skill/design-md/pinterest/DESIGN.md +597 -0
- package/skills/fui-skill/design-md/pinterest/README.md +5 -0
- package/skills/fui-skill/design-md/playstation/DESIGN.md +661 -0
- package/skills/fui-skill/design-md/playstation/README.md +5 -0
- package/skills/fui-skill/design-md/posthog/DESIGN.md +690 -0
- package/skills/fui-skill/design-md/posthog/README.md +5 -0
- package/skills/fui-skill/design-md/raycast/DESIGN.md +669 -0
- package/skills/fui-skill/design-md/raycast/README.md +5 -0
- package/skills/fui-skill/design-md/renault/DESIGN.md +589 -0
- package/skills/fui-skill/design-md/renault/README.md +5 -0
- package/skills/fui-skill/design-md/replicate/DESIGN.md +616 -0
- package/skills/fui-skill/design-md/replicate/README.md +5 -0
- package/skills/fui-skill/design-md/resend/DESIGN.md +585 -0
- package/skills/fui-skill/design-md/resend/README.md +5 -0
- package/skills/fui-skill/design-md/revolut/DESIGN.md +636 -0
- package/skills/fui-skill/design-md/revolut/README.md +5 -0
- package/skills/fui-skill/design-md/runwayml/DESIGN.md +244 -0
- package/skills/fui-skill/design-md/runwayml/README.md +5 -0
- package/skills/fui-skill/design-md/sanity/DESIGN.md +357 -0
- package/skills/fui-skill/design-md/sanity/README.md +5 -0
- package/skills/fui-skill/design-md/sentry/DESIGN.md +551 -0
- package/skills/fui-skill/design-md/sentry/README.md +5 -0
- package/skills/fui-skill/design-md/shopify/DESIGN.md +516 -0
- package/skills/fui-skill/design-md/shopify/README.md +5 -0
- package/skills/fui-skill/design-md/slack/DESIGN.md +482 -0
- package/skills/fui-skill/design-md/spacex/DESIGN.md +363 -0
- package/skills/fui-skill/design-md/spacex/README.md +5 -0
- package/skills/fui-skill/design-md/spotify/DESIGN.md +246 -0
- package/skills/fui-skill/design-md/spotify/README.md +5 -0
- package/skills/fui-skill/design-md/starbucks/DESIGN.md +580 -0
- package/skills/fui-skill/design-md/starbucks/README.md +5 -0
- package/skills/fui-skill/design-md/stripe/DESIGN.md +487 -0
- package/skills/fui-skill/design-md/stripe/README.md +5 -0
- package/skills/fui-skill/design-md/supabase/DESIGN.md +462 -0
- package/skills/fui-skill/design-md/supabase/README.md +5 -0
- package/skills/fui-skill/design-md/superhuman/DESIGN.md +448 -0
- package/skills/fui-skill/design-md/superhuman/README.md +5 -0
- package/skills/fui-skill/design-md/tesla/DESIGN.md +286 -0
- package/skills/fui-skill/design-md/tesla/README.md +5 -0
- package/skills/fui-skill/design-md/theverge/DESIGN.md +339 -0
- package/skills/fui-skill/design-md/theverge/README.md +5 -0
- package/skills/fui-skill/design-md/together.ai/DESIGN.md +633 -0
- package/skills/fui-skill/design-md/together.ai/README.md +5 -0
- package/skills/fui-skill/design-md/uber/DESIGN.md +636 -0
- package/skills/fui-skill/design-md/uber/README.md +5 -0
- package/skills/fui-skill/design-md/vercel/DESIGN.md +736 -0
- package/skills/fui-skill/design-md/vercel/README.md +5 -0
- package/skills/fui-skill/design-md/vodafone/DESIGN.md +538 -0
- package/skills/fui-skill/design-md/vodafone/README.md +5 -0
- package/skills/fui-skill/design-md/voltagent/DESIGN.md +521 -0
- package/skills/fui-skill/design-md/voltagent/README.md +5 -0
- package/skills/fui-skill/design-md/warp/DESIGN.md +526 -0
- package/skills/fui-skill/design-md/warp/README.md +5 -0
- package/skills/fui-skill/design-md/webflow/DESIGN.md +588 -0
- package/skills/fui-skill/design-md/webflow/README.md +5 -0
- package/skills/fui-skill/design-md/wired/DESIGN.md +497 -0
- package/skills/fui-skill/design-md/wired/README.md +5 -0
- package/skills/fui-skill/design-md/wise/DESIGN.md +544 -0
- package/skills/fui-skill/design-md/wise/README.md +5 -0
- package/skills/fui-skill/design-md/x.ai/DESIGN.md +465 -0
- package/skills/fui-skill/design-md/x.ai/README.md +5 -0
- package/skills/fui-skill/design-md/zapier/DESIGN.md +537 -0
- package/skills/fui-skill/design-md/zapier/README.md +5 -0
- package/skills/fui-skill/examples/component.vue +162 -0
- package/skills/fui-skill/examples/f-table-patterns.json +331 -0
- package/skills/fui-skill/examples/module-patterns.json +973 -0
- package/skills/fui-skill/examples/project-patterns.json +222 -0
- package/skills/fui-skill/metadata.json +75 -0
- package/skills/fui-skill/references/INDEX.md +144 -0
- package/skills/fui-skill/references/advanced-techniques.md +160 -0
- package/skills/fui-skill/references/coding-standards.md +112 -0
- package/skills/fui-skill/references/component-design.md +455 -0
- package/skills/fui-skill/references/component-quickref.md +77 -0
- package/skills/fui-skill/references/component-table.md +276 -0
- package/skills/fui-skill/references/components-dialog.md +192 -0
- package/skills/fui-skill/references/components-display.md +147 -0
- package/skills/fui-skill/references/components-echart.md +391 -0
- package/skills/fui-skill/references/components-input.md +359 -0
- package/skills/fui-skill/references/controls-patterns.md +847 -0
- package/skills/fui-skill/references/controls-styling-vocabulary.md +140 -0
- package/skills/fui-skill/references/db-table-design.md +77 -0
- package/skills/fui-skill/references/db-workflow.md +504 -0
- package/skills/fui-skill/references/default-function.md +415 -0
- package/skills/fui-skill/references/design-modes.md +85 -0
- package/skills/fui-skill/references/echart-templates.md +481 -0
- package/skills/fui-skill/references/fastproject.md +97 -0
- package/skills/fui-skill/references/fsheet.md +218 -0
- package/skills/fui-skill/references/fullstack-workflow.md +351 -0
- package/skills/fui-skill/references/module-data-patterns.md +126 -0
- package/skills/fui-skill/references/module-json-anatomy.md +137 -0
- package/skills/fui-skill/references/module-structure.md +260 -0
- package/skills/fui-skill/references/new-session.md +108 -0
- package/skills/fui-skill/references/pdfmake.md +60 -0
- package/skills/fui-skill/references/permission-system.md +169 -0
- package/skills/fui-skill/references/platform-architecture.md +294 -0
- package/skills/fui-skill/references/project-config.md +335 -0
- package/skills/fui-skill/references/project-provisioning.md +383 -0
- package/skills/fui-skill/references/script-map.md +296 -0
- package/skills/fui-skill/references/sql-clr-functions.md +224 -0
- package/skills/fui-skill/references/system-design.md +116 -0
- package/skills/fui-skill/references/tapi-file-api.md +191 -0
- package/skills/fui-skill/references/tapi-permission-patterns.md +158 -0
- package/skills/fui-skill/references/tapi-reference.md +549 -0
- package/skills/fui-skill/references/tools-registry.md +460 -0
- package/skills/fui-skill/references/ui-crosswindow-patterns.md +317 -0
- package/skills/fui-skill/references/ui-dialog-patterns.md +229 -0
- package/skills/fui-skill/references/ui-layout-patterns.md +176 -0
- package/skills/fui-skill/references/ui-patterns.md +315 -0
- package/skills/fui-skill/references/ui-screenshot-review.md +94 -0
- package/skills/fui-skill/references/ui-table-cell-patterns.md +316 -0
- package/skills/fui-skill/references/ui-templates.md +29 -0
- package/skills/fui-skill/references/verification.md +246 -0
- package/skills/fui-skill/references/watcher-patterns.md +196 -0
- package/skills/fui-skill/references/websocket-realtime.md +254 -0
- package/skills/fui-skill/scripts/component-3.0.js +2549 -0
- package/skills/fui-skill/scripts/component.js +3142 -0
- package/skills/fui-skill/scripts/componentTable-3.0.js +909 -0
- package/skills/fui-skill/scripts/componentTable.js +769 -0
- package/skills/fui-skill/scripts/defaultfunction-3.0.js +781 -0
- package/skills/fui-skill/scripts/defaultfunction.js +966 -0
- package/skills/fui-skill/scripts/fastproject-3.0.js +870 -0
- package/skills/fui-skill/scripts/fastproject.js +828 -0
- package/skills/fui-skill/scripts/fechart.js +890 -0
- package/skills/fui-skill/scripts/fsheet.js +1330 -0
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* FEchart V5 — Direct Mode
|
|
3
|
+
* Vue 2 + Apache ECharts + Lodash
|
|
4
|
+
*
|
|
5
|
+
* Architecture:
|
|
6
|
+
* FEchartFormatter — value formatters (public, dùng trong script.js)
|
|
7
|
+
* Vue.component('f-echart') — direct-mode chart wrapper
|
|
8
|
+
*
|
|
9
|
+
* Theme mặc định: 'shine' (nhúng sẵn, không cần load file ngoài).
|
|
10
|
+
* Đăng ký theme khác: gọi echarts.registerTheme(...) trước khi mount.
|
|
11
|
+
*
|
|
12
|
+
* Props:
|
|
13
|
+
* data {Array} flat array — optional, chỉ dùng cho DataView table
|
|
14
|
+
* config {Object} meta / shortcut settings (xem danh sách bên dưới)
|
|
15
|
+
* option {Object} full ECharts option — primary; thắng mọi config shortcut
|
|
16
|
+
*
|
|
17
|
+
* config keys:
|
|
18
|
+
* height number|string chiều cao (default 400px hoặc '50vh')
|
|
19
|
+
* theme string tên theme (default 'shine'); null = không dùng theme
|
|
20
|
+
* title string → option.title.text
|
|
21
|
+
* subtitle string → option.title.subtext
|
|
22
|
+
* titleLeft string 'left'|'center'|'right'
|
|
23
|
+
* titleStyle Object ECharts textStyle cho title
|
|
24
|
+
* color string[] override color palette
|
|
25
|
+
* backgroundColor string override background (default 'transparent')
|
|
26
|
+
* animation boolean false = tắt animation
|
|
27
|
+
* animationDuration number ms
|
|
28
|
+
* animationEasing string ECharts easing name
|
|
29
|
+
* legend false|Object false = ẩn; Object = merge vào default legend
|
|
30
|
+
* legendConfig Object extra legend props (merge thêm)
|
|
31
|
+
* tooltip Object merge vào default tooltip { confine: true }
|
|
32
|
+
* tooltipFormat string 'number'|'percent'|'currency'|'short'|'auto'
|
|
33
|
+
* 'auto' (mặc định) = số đầy đủ có phân cách nghìn (1,500,000);
|
|
34
|
+
* 'short' = co gọn (1.5M) — chỉ khi khai báo tường minh
|
|
35
|
+
* tooltipFormatter Function custom tooltip fn
|
|
36
|
+
*
|
|
37
|
+
* Tooltip mặc định (không cần cấu hình): title canh giữa, mỗi dòng series
|
|
38
|
+
* label canh trái + giá trị canh phải (số đầy đủ, phân cách nghìn).
|
|
39
|
+
* toolbox boolean true = hiện toolbox (saveAsImage + restore); ẩn mặc định
|
|
40
|
+
* toolboxConfig Object override/extend toolbox feature config
|
|
41
|
+
* dataView boolean true = thêm nút DataView; implies toolbox: true
|
|
42
|
+
* zoom boolean true = thêm dataZoom (inside + slider)
|
|
43
|
+
* zoomConfig Array override dataZoom config
|
|
44
|
+
* refreshInterval number ms — auto-refresh interval
|
|
45
|
+
*
|
|
46
|
+
* Bar series — tự động (không cần cấu hình thêm):
|
|
47
|
+
* Fill màu nhạt hơn bộ màu theme, viền giữ nguyên màu gốc.
|
|
48
|
+
* Override bằng cách set itemStyle.color trong option.series[].
|
|
49
|
+
*
|
|
50
|
+
* Events:
|
|
51
|
+
* chart-click (params) click trên data point
|
|
52
|
+
* drilldown (params) alias của chart-click
|
|
53
|
+
* legend-change (params) chọn/bỏ legend item
|
|
54
|
+
* rendered () sau mỗi lần setOption thành công
|
|
55
|
+
* finished () sau khi animation hoàn thành
|
|
56
|
+
*
|
|
57
|
+
* Exposed (via $refs):
|
|
58
|
+
* getInstance() → ECharts instance gốc
|
|
59
|
+
* refresh() → force re-render
|
|
60
|
+
* exportImage(name) → download PNG
|
|
61
|
+
* resizeChart() → manual resize
|
|
62
|
+
* ============================================================ */
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
/* ============================================================
|
|
66
|
+
* Chart theme system
|
|
67
|
+
* Themes: 'fui' (default, 56 màu) · 'shine' (8 màu cổ điển) · 'v5' (ECharts V5 default)
|
|
68
|
+
* config.theme: 'fui' | 'shine' | 'v5' — mặc định 'fui'
|
|
69
|
+
* ============================================================ */
|
|
70
|
+
|
|
71
|
+
// Pha màu với trắng: amount = 0 (không đổi) → 1 (trắng hoàn toàn)
|
|
72
|
+
const _lightenHex = (hex, amount) => {
|
|
73
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
74
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
75
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
76
|
+
const m = v => Math.round(v + (255 - v) * amount).toString(16).padStart(2, '0');
|
|
77
|
+
return '#' + m(r) + m(g) + m(b);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const _paletteFui = [
|
|
81
|
+
'#1e90ff','#ffa64d','#6fdc6f','#f14aff','#9966ff','#02e8e8','#402b58','#3b8d74',
|
|
82
|
+
'#095fa4','#b63639','#c84f7a','#a8b236','#90692f','#b42e89','#ff9966','#4db8ff',
|
|
83
|
+
'#1c8540','#ff6699','#5666ad','#b2b266','#e2976b','#84c887','#4d9dd0','#a187f7',
|
|
84
|
+
'#d9745b','#862929','#ea80fc','#8c4391','#ff8c00','#53915b','#6dcbab','#7b7353',
|
|
85
|
+
'#d4e157','#cc242f','#c69e31','#fdd835',
|
|
86
|
+
'#4E79A7','#A0CBE8','#F28E2B','#FFBE7D','#59A14F','#8CD17D','#B6992D','#F1CE63',
|
|
87
|
+
'#499894','#86BCB6','#E15759','#FF9D9A','#79706E','#BAB0AC','#D37295','#FABFD2',
|
|
88
|
+
'#B07AA1','#D4A6C8','#9D7660','#D7B5A6'
|
|
89
|
+
];
|
|
90
|
+
const _paletteShine = ['#c12e34','#e6b600','#0098d9','#2b821d','#005eaa','#339ca8','#cda819','#32a487'];
|
|
91
|
+
const _paletteV5 = ['#5470c6','#91cc75','#fac858','#ee6666','#73c0de','#3ba272','#fc8452','#9a60b4','#ea7ccc'];
|
|
92
|
+
|
|
93
|
+
// Font cho toàn bộ text trong chart (title, axis, legend, tooltip). ECharts mặc định
|
|
94
|
+
// dùng font hệ thống chung chung (thường ra serif-ish trên một số OS) — set rõ ràng
|
|
95
|
+
// để khớp UI còn lại của FUI.
|
|
96
|
+
const DEFAULT_FONT_FAMILY = 'Roboto, sans-serif, Arial, Tahoma';
|
|
97
|
+
|
|
98
|
+
// ── Mô hình khoảng trống (layout box model) ────────────────────────────────
|
|
99
|
+
// ECharts đặt title / legend / vùng vẽ bằng toạ độ tuyệt đối, không có khái niệm
|
|
100
|
+
// "khối này nằm dưới khối kia" — nên trước đây mỗi khối phải tự đoán một con số
|
|
101
|
+
// top/bottom cố định, và chỉ cần đổi cỡ chữ title hay chuyển legend sang cạnh khác
|
|
102
|
+
// là hai khối dính vào nhau.
|
|
103
|
+
//
|
|
104
|
+
// Cấu trúc thay thế: MỖI KHỐI LUÔN CÓ KHOẢNG HỞ `gap` QUANH MÌNH, và vùng vẽ
|
|
105
|
+
// (grid với chart có hệ trục, hoặc box của pie/funnel với chart không trục)
|
|
106
|
+
// nhận phần còn lại sau khi CỘNG DỒN chiều cao + khoảng hở của những khối neo
|
|
107
|
+
// cùng cạnh với nó (xem `applyLayout`). Thêm/bớt/di chuyển một khối chỉ làm thay
|
|
108
|
+
// đổi tổng cộng dồn, không bao giờ làm chồng lấn.
|
|
109
|
+
const LAYOUT = {
|
|
110
|
+
padding: 8, // khoảng hở với mép ngoài canvas
|
|
111
|
+
gap: 12, // khoảng hở tối thiểu giữa hai khối bất kỳ
|
|
112
|
+
gridTop: 50, // padding mặc định của vùng vẽ
|
|
113
|
+
gridBottom: 50,
|
|
114
|
+
gridLeft: 0,
|
|
115
|
+
gridRight: 0,
|
|
116
|
+
titleFontSize: 16, // cỡ chữ title mặc định (config.titleSize để đổi)
|
|
117
|
+
titleGap: 4, // title ↔ subtitle
|
|
118
|
+
subtitleFontSize: 12,
|
|
119
|
+
lineHeight: 1.4, // hệ số quy cỡ chữ → chiều cao dòng thực tế
|
|
120
|
+
legendRowHeight: 24, // một hàng legend (chữ + đệm của item); config.legendRows nếu nhiều hàng
|
|
121
|
+
axisLabelBand: 26, // nhãn trục hoành vẽ NGOÀI grid, phải chừa thêm ở dưới
|
|
122
|
+
axisTopBand: 10 // nửa chữ của nhãn trục tung cao nhất nhô lên trên mép grid
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
/* ============================================================
|
|
127
|
+
* FEchartFormatter
|
|
128
|
+
* Utility: format values for tooltip, label, axis tick
|
|
129
|
+
* ============================================================ */
|
|
130
|
+
const FEchartFormatter = (() => {
|
|
131
|
+
const _formatNumber = (val, decimals = 0) => {
|
|
132
|
+
if (val == null || isNaN(val)) return val;
|
|
133
|
+
return Number(val).toLocaleString('en-US', {
|
|
134
|
+
minimumFractionDigits: decimals,
|
|
135
|
+
maximumFractionDigits: decimals
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const _formatPercent = (val, decimals = 1) =>
|
|
140
|
+
val == null ? val : Number(val).toFixed(decimals) + '%';
|
|
141
|
+
|
|
142
|
+
const _formatCurrency = (val, symbol = '₫', decimals = 0) =>
|
|
143
|
+
val == null ? val : symbol + _formatNumber(val, decimals);
|
|
144
|
+
|
|
145
|
+
const _formatShort = (val) => {
|
|
146
|
+
if (val == null || isNaN(val)) return val;
|
|
147
|
+
const abs = Math.abs(val);
|
|
148
|
+
if (abs >= 1e9) return (val / 1e9).toFixed(1) + 'B';
|
|
149
|
+
if (abs >= 1e6) return (val / 1e6).toFixed(1) + 'M';
|
|
150
|
+
if (abs >= 1e3) return (val / 1e3).toFixed(1) + 'K';
|
|
151
|
+
return String(val);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Số đầy đủ, phân cách nghìn, giữ tối đa 2 chữ số thập phân (2535 → '2,535'; 12.345 → '12.35')
|
|
155
|
+
const _formatFull = (val) => {
|
|
156
|
+
if (val == null || isNaN(val)) return val;
|
|
157
|
+
return Number(val).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build an ECharts formatter function.
|
|
162
|
+
* @param {string|Function} fmt - 'number'|'percent'|'currency'|'short'|'auto'|custom fn
|
|
163
|
+
* @param {Object} opts - { decimals, symbol }
|
|
164
|
+
* @returns {Function}
|
|
165
|
+
*/
|
|
166
|
+
const build = (fmt, opts = {}) => {
|
|
167
|
+
if (typeof fmt === 'function') return fmt;
|
|
168
|
+
const { decimals, symbol } = opts;
|
|
169
|
+
switch (fmt) {
|
|
170
|
+
case 'percent': return v => _formatPercent(v, decimals);
|
|
171
|
+
case 'currency': return v => _formatCurrency(v, symbol, decimals);
|
|
172
|
+
case 'short': return v => _formatShort(v);
|
|
173
|
+
case 'number': return v => _formatNumber(v, decimals);
|
|
174
|
+
case 'auto':
|
|
175
|
+
default: return v => _formatFull(v); // số đầy đủ — muốn co gọn dùng 'short'
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build an ECharts tooltip formatter from config.
|
|
181
|
+
* Hỗ trợ cả dataset mode (p.value là object row) và series.data mode (p.value là số).
|
|
182
|
+
* @param {Object} config - { tooltipFormat, tooltipFormatter }
|
|
183
|
+
* @returns {Function|undefined}
|
|
184
|
+
*/
|
|
185
|
+
const buildTooltip = (config) => {
|
|
186
|
+
if (config.tooltipFormatter) return config.tooltipFormatter;
|
|
187
|
+
if (!config.tooltipFormat) return undefined;
|
|
188
|
+
|
|
189
|
+
const fmt = build(config.tooltipFormat, config);
|
|
190
|
+
return params => _renderTooltip(params, fmt);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Trích giá trị số từ ECharts params (mọi mode: dataset object/array, series.data)
|
|
194
|
+
const _extractVal = (p) => {
|
|
195
|
+
const v = p.value;
|
|
196
|
+
if (v == null) return null;
|
|
197
|
+
if (typeof v === 'number') return v;
|
|
198
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
199
|
+
const dims = p.dimensionNames;
|
|
200
|
+
const yIdx = p.encode?.y?.[0] ?? null;
|
|
201
|
+
if (yIdx != null && dims?.[yIdx] != null) { const n = v[dims[yIdx]]; if (typeof n === 'number') return n; }
|
|
202
|
+
if (p.seriesName && typeof v[p.seriesName] === 'number') return v[p.seriesName];
|
|
203
|
+
return Object.values(v).find(x => typeof x === 'number') ?? null;
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(v)) { const y = p.encode?.y?.[0]; return y != null ? v[y] : v[v.length - 1]; }
|
|
206
|
+
return null;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Layout tooltip chuẩn (dùng chung cho mọi tooltipFormat lẫn mặc định):
|
|
210
|
+
// title canh giữa, mỗi dòng series: chấm màu + label canh trái, giá trị canh phải.
|
|
211
|
+
const _renderTooltip = (params, fmt) => {
|
|
212
|
+
const arr = Array.isArray(params) ? params : [params];
|
|
213
|
+
const title = arr[0]?.name || arr[0]?.axisValueLabel || '';
|
|
214
|
+
const rows = arr.map(p => {
|
|
215
|
+
const raw = _extractVal(p);
|
|
216
|
+
const val = raw != null ? fmt(raw) : (p.value != null ? p.value : '–');
|
|
217
|
+
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:4px">` +
|
|
218
|
+
`<span style="display:flex;align-items:center;gap:6px;flex:1;text-align:left">` +
|
|
219
|
+
`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color};flex-shrink:0"></span>` +
|
|
220
|
+
`<span>${p.seriesName || p.name || ''}</span></span>` +
|
|
221
|
+
`<b style="text-align:right">${val}</b></div>`;
|
|
222
|
+
});
|
|
223
|
+
return `<div style="font-family:${DEFAULT_FONT_FAMILY};font-size:13px;text-align:left;min-width:130px">` +
|
|
224
|
+
(title ? `<div style="font-weight:600;text-align:center;margin-bottom:5px">${title}</div>` : '') +
|
|
225
|
+
rows.join('') + `</div>`;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Formatter mặc định — số đầy đủ có phân cách nghìn (không co gọn kiểu 1.2K).
|
|
229
|
+
const buildDefault = () => (params) => _renderTooltip(params, _formatFull);
|
|
230
|
+
|
|
231
|
+
return { build, buildTooltip, buildDefault, formatNumber: _formatNumber, formatShort: _formatShort, formatFull: _formatFull };
|
|
232
|
+
})();
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
// Build shine-style theme from a color palette (classic axis style)
|
|
236
|
+
const _mkShineTheme = (colors) => ({
|
|
237
|
+
color: colors,
|
|
238
|
+
backgroundColor: 'rgba(0,0,0,0)',
|
|
239
|
+
textStyle: { fontFamily: DEFAULT_FONT_FAMILY },
|
|
240
|
+
title: { top: 8, textStyle: { color: '#333333', fontFamily: DEFAULT_FONT_FAMILY }, subtextStyle: { color: '#aaa', fontFamily: DEFAULT_FONT_FAMILY } },
|
|
241
|
+
line: { itemStyle: { borderWidth: 1 }, lineStyle: { width: 2 }, symbolSize: 4, symbol: 'emptyCircle', smooth: false },
|
|
242
|
+
radar: { itemStyle: { borderWidth: 1 }, lineStyle: { width: 2 }, symbolSize: 4, symbol: 'emptyCircle', smooth: false },
|
|
243
|
+
bar: { itemStyle: { barBorderWidth: 0, barBorderColor: '#ccc' } },
|
|
244
|
+
pie: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
245
|
+
scatter: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
246
|
+
boxplot: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
247
|
+
parallel:{ itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
248
|
+
sankey: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
249
|
+
funnel: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
250
|
+
gauge: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
251
|
+
candlestick: { itemStyle: { color: '#c12e34', color0: '#2b821d', borderColor: '#c12e34', borderColor0: '#2b821d', borderWidth: 1 } },
|
|
252
|
+
graph: {
|
|
253
|
+
itemStyle: { borderWidth: 0, borderColor: '#ccc' },
|
|
254
|
+
lineStyle: { width: 1, color: '#aaa' },
|
|
255
|
+
symbolSize: 4, symbol: 'emptyCircle', smooth: false,
|
|
256
|
+
color: colors, label: { color: '#eee' }
|
|
257
|
+
},
|
|
258
|
+
map: {
|
|
259
|
+
itemStyle: { areaColor: '#ddd', borderColor: '#eee', borderWidth: 0.5 },
|
|
260
|
+
label: { color: '#c12e34' },
|
|
261
|
+
emphasis: { itemStyle: { areaColor: '#e6b600', borderColor: '#ddd', borderWidth: 1 }, label: { color: '#c12e34' } }
|
|
262
|
+
},
|
|
263
|
+
geo: {
|
|
264
|
+
itemStyle: { areaColor: '#ddd', borderColor: '#eee', borderWidth: 0.5 },
|
|
265
|
+
label: { color: '#c12e34' },
|
|
266
|
+
emphasis: { itemStyle: { areaColor: '#e6b600', borderColor: '#ddd', borderWidth: 1 }, label: { color: '#c12e34' } }
|
|
267
|
+
},
|
|
268
|
+
categoryAxis: {
|
|
269
|
+
axisLine: { show: true, lineStyle: { color: '#333' } },
|
|
270
|
+
axisTick: { show: true, lineStyle: { color: '#333' } },
|
|
271
|
+
axisLabel: { show: true, color: '#333', fontFamily: DEFAULT_FONT_FAMILY },
|
|
272
|
+
splitLine: { show: false, lineStyle: { color: ['#ccc'] } },
|
|
273
|
+
splitArea: { show: false, areaStyle: { color: ['rgba(250,250,250,0.3)','rgba(200,200,200,0.3)'] } }
|
|
274
|
+
},
|
|
275
|
+
valueAxis: {
|
|
276
|
+
axisLine: { show: true, lineStyle: { color: '#333' } },
|
|
277
|
+
axisTick: { show: true, lineStyle: { color: '#333' } },
|
|
278
|
+
axisLabel: { show: true, color: '#333', fontFamily: DEFAULT_FONT_FAMILY },
|
|
279
|
+
splitLine: { show: true, lineStyle: { color: ['#ccc'] } },
|
|
280
|
+
splitArea: { show: false, areaStyle: { color: ['rgba(250,250,250,0.3)','rgba(200,200,200,0.3)'] } }
|
|
281
|
+
},
|
|
282
|
+
logAxis: {
|
|
283
|
+
axisLine: { show: true, lineStyle: { color: '#333' } },
|
|
284
|
+
axisTick: { show: true, lineStyle: { color: '#333' } },
|
|
285
|
+
axisLabel: { show: true, color: '#333', fontFamily: DEFAULT_FONT_FAMILY },
|
|
286
|
+
splitLine: { show: true, lineStyle: { color: ['#ccc'] } },
|
|
287
|
+
splitArea: { show: false, areaStyle: { color: ['rgba(250,250,250,0.3)','rgba(200,200,200,0.3)'] } }
|
|
288
|
+
},
|
|
289
|
+
timeAxis: {
|
|
290
|
+
axisLine: { show: true, lineStyle: { color: '#333' } },
|
|
291
|
+
axisTick: { show: true, lineStyle: { color: '#333' } },
|
|
292
|
+
axisLabel: { show: true, color: '#333', fontFamily: DEFAULT_FONT_FAMILY },
|
|
293
|
+
splitLine: { show: true, lineStyle: { color: ['#ccc'] } },
|
|
294
|
+
splitArea: { show: false, areaStyle: { color: ['rgba(250,250,250,0.3)','rgba(200,200,200,0.3)'] } }
|
|
295
|
+
},
|
|
296
|
+
toolbox: { iconStyle: { borderColor: '#06467c' }, emphasis: { iconStyle: { borderColor: '#4187c2' } } },
|
|
297
|
+
// Vị trí dọc của legend/title/grid do `applyLayout` tính (xem LAYOUT) — theme chỉ lo style.
|
|
298
|
+
legend: { textStyle: { color: '#333333', fontFamily: DEFAULT_FONT_FAMILY }, left: 'center', right: 'auto' },
|
|
299
|
+
tooltip: { axisPointer: { lineStyle: { color: '#cccccc', width: 1 }, crossStyle: { color: '#cccccc', width: 1 } }, textStyle: { fontFamily: DEFAULT_FONT_FAMILY } },
|
|
300
|
+
timeline: {
|
|
301
|
+
lineStyle: { color: '#005eaa', width: 1 }, itemStyle: { color: '#005eaa', borderWidth: 1 },
|
|
302
|
+
controlStyle: { color: '#005eaa', borderColor: '#005eaa', borderWidth: 0.5 },
|
|
303
|
+
checkpointStyle: { color: '#005eaa', borderColor: 'rgba(49,107,194,0.5)' },
|
|
304
|
+
label: { color: '#005eaa' },
|
|
305
|
+
emphasis: {
|
|
306
|
+
itemStyle: { color: '#005eaa' },
|
|
307
|
+
controlStyle: { color: '#005eaa', borderColor: '#005eaa', borderWidth: 0.5 },
|
|
308
|
+
label: { color: '#005eaa' }
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
visualMap: { color: ['#1790cf','#a2d4e6'] },
|
|
312
|
+
markPoint: { label: { color: '#eee' }, emphasis: { label: { color: '#eee' } } },
|
|
313
|
+
// top/bottom chỉ là mức dự phòng khi module tự khai `grid` — bình thường `applyLayout` ghi đè
|
|
314
|
+
grid: { left: '10%', right: '10%', top: 60, bottom: 60 }
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Enhancement policy — phần của theme, kiểm soát cách áp style lên từng series type.
|
|
318
|
+
// lighten : fill nhạt + viền màu gốc (per-item với pie/funnel, per-series với bar)
|
|
319
|
+
// separator: viền trắng mờ phân cách (per-series, dùng cho khối phân cấp)
|
|
320
|
+
// outline : viền trắng quanh điểm (per-series, dùng cho scatter)
|
|
321
|
+
const _mkEnhance = (lightenAmount = 0.5, separatorColor = 'rgba(255,255,255,0.75)') => ({
|
|
322
|
+
lighten: ['bar', 'pie', 'funnel'],
|
|
323
|
+
separator: ['treemap', 'sunburst'],
|
|
324
|
+
outline: ['scatter', 'effectScatter'],
|
|
325
|
+
lightenAmount,
|
|
326
|
+
separatorColor,
|
|
327
|
+
outlineColor: 'rgba(255,255,255,0.8)',
|
|
328
|
+
outlineWidth: 1.5
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const _themes = {
|
|
332
|
+
fui: { colors: _paletteFui, def: _mkShineTheme(_paletteFui), enhance: _mkEnhance() },
|
|
333
|
+
shine: { colors: _paletteShine, def: _mkShineTheme(_paletteShine), enhance: _mkEnhance() },
|
|
334
|
+
v5: {
|
|
335
|
+
colors: _paletteV5,
|
|
336
|
+
enhance: _mkEnhance(0.45, 'rgba(255,255,255,0.8)'),
|
|
337
|
+
def: {
|
|
338
|
+
color: _paletteV5,
|
|
339
|
+
backgroundColor: 'rgba(0,0,0,0)',
|
|
340
|
+
textStyle: { fontFamily: DEFAULT_FONT_FAMILY },
|
|
341
|
+
title: { top: 8, textStyle: { color: '#464646', fontFamily: DEFAULT_FONT_FAMILY }, subtextStyle: { color: '#6E7079', fontFamily: DEFAULT_FONT_FAMILY } },
|
|
342
|
+
line: { itemStyle: { borderWidth: 1 }, lineStyle: { width: 2 }, symbolSize: 4, symbol: 'emptyCircle', smooth: false },
|
|
343
|
+
radar: { itemStyle: { borderWidth: 1 }, lineStyle: { width: 2 }, symbolSize: 4, symbol: 'emptyCircle', smooth: false },
|
|
344
|
+
bar: { itemStyle: { barBorderWidth: 0, barBorderColor: '#ccc' } },
|
|
345
|
+
pie: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
346
|
+
scatter: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
347
|
+
boxplot: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
348
|
+
parallel:{ itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
349
|
+
sankey: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
350
|
+
funnel: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
351
|
+
gauge: { itemStyle: { borderWidth: 0, borderColor: '#ccc' } },
|
|
352
|
+
candlestick: { itemStyle: { color: '#eb5454', color0: '#47b262', borderColor: '#eb5454', borderColor0: '#47b262', borderWidth: 1 } },
|
|
353
|
+
graph: {
|
|
354
|
+
itemStyle: { borderWidth: 0, borderColor: '#ccc' },
|
|
355
|
+
lineStyle: { width: 1, color: '#aaa' },
|
|
356
|
+
symbolSize: 4, symbol: 'emptyCircle', smooth: false,
|
|
357
|
+
color: _paletteV5, label: { color: '#eee' }
|
|
358
|
+
},
|
|
359
|
+
map: {
|
|
360
|
+
itemStyle: { areaColor: '#eee', borderColor: '#444', borderWidth: 0.5 },
|
|
361
|
+
label: { color: '#000' },
|
|
362
|
+
emphasis: { itemStyle: { areaColor: 'rgba(255,215,0,0.8)', borderColor: '#444', borderWidth: 1 }, label: { color: 'rgb(100,0,0)' } }
|
|
363
|
+
},
|
|
364
|
+
geo: {
|
|
365
|
+
itemStyle: { areaColor: '#eee', borderColor: '#444', borderWidth: 0.5 },
|
|
366
|
+
label: { color: '#000' },
|
|
367
|
+
emphasis: { itemStyle: { areaColor: 'rgba(255,215,0,0.8)', borderColor: '#444', borderWidth: 1 }, label: { color: 'rgb(100,0,0)' } }
|
|
368
|
+
},
|
|
369
|
+
categoryAxis: {
|
|
370
|
+
axisLine: { show: true, lineStyle: { color: '#6E7079' } },
|
|
371
|
+
axisTick: { show: true, lineStyle: { color: '#6E7079' } },
|
|
372
|
+
axisLabel: { show: true, color: '#6E7079', fontFamily: DEFAULT_FONT_FAMILY },
|
|
373
|
+
splitLine: { show: false, lineStyle: { color: ['#E0E6F1'] } },
|
|
374
|
+
splitArea: { show: false, areaStyle: { color: ['rgba(250,250,250,0.2)','rgba(210,219,238,0.2)'] } }
|
|
375
|
+
},
|
|
376
|
+
valueAxis: {
|
|
377
|
+
axisLine: { show: false, lineStyle: { color: '#6E7079' } },
|
|
378
|
+
axisTick: { show: false, lineStyle: { color: '#6E7079' } },
|
|
379
|
+
axisLabel: { show: true, color: '#6E7079', fontFamily: DEFAULT_FONT_FAMILY },
|
|
380
|
+
splitLine: { show: true, lineStyle: { color: ['#E0E6F1'] } },
|
|
381
|
+
splitArea: { show: false, areaStyle: { color: ['rgba(250,250,250,0.2)','rgba(210,219,238,0.2)'] } }
|
|
382
|
+
},
|
|
383
|
+
logAxis: {
|
|
384
|
+
axisLine: { show: false, lineStyle: { color: '#6E7079' } },
|
|
385
|
+
axisTick: { show: false, lineStyle: { color: '#6E7079' } },
|
|
386
|
+
axisLabel: { show: true, color: '#6E7079', fontFamily: DEFAULT_FONT_FAMILY },
|
|
387
|
+
splitLine: { show: true, lineStyle: { color: ['#E0E6F1'] } },
|
|
388
|
+
splitArea: { show: false, areaStyle: { color: ['rgba(250,250,250,0.2)','rgba(210,219,238,0.2)'] } }
|
|
389
|
+
},
|
|
390
|
+
timeAxis: {
|
|
391
|
+
axisLine: { show: true, lineStyle: { color: '#6E7079' } },
|
|
392
|
+
axisTick: { show: true, lineStyle: { color: '#6E7079' } },
|
|
393
|
+
axisLabel: { show: true, color: '#6E7079', fontFamily: DEFAULT_FONT_FAMILY },
|
|
394
|
+
splitLine: { show: false, lineStyle: { color: ['#E0E6F1'] } },
|
|
395
|
+
splitArea: { show: false, areaStyle: { color: ['rgba(250,250,250,0.2)','rgba(210,219,238,0.2)'] } }
|
|
396
|
+
},
|
|
397
|
+
toolbox: { iconStyle: { borderColor: '#999' }, emphasis: { iconStyle: { borderColor: '#666' } } },
|
|
398
|
+
legend: { textStyle: { color: '#333', fontFamily: DEFAULT_FONT_FAMILY }, left: 'center', right: 'auto' },
|
|
399
|
+
tooltip: { axisPointer: { lineStyle: { color: '#ccc', width: 1 }, crossStyle: { color: '#ccc', width: 1 } }, textStyle: { fontFamily: DEFAULT_FONT_FAMILY } },
|
|
400
|
+
timeline: {
|
|
401
|
+
lineStyle: { color: '#DAE1F5', width: 2 }, itemStyle: { color: '#A4B1D7', borderWidth: 1 },
|
|
402
|
+
controlStyle: { color: '#A4B1D7', borderColor: '#A4B1D7', borderWidth: 1 },
|
|
403
|
+
checkpointStyle: { color: '#316bf3', borderColor: '#fff' },
|
|
404
|
+
label: { color: '#A4B1D7' },
|
|
405
|
+
emphasis: {
|
|
406
|
+
itemStyle: { color: '#FFF' },
|
|
407
|
+
controlStyle: { color: '#A4B1D7', borderColor: '#A4B1D7', borderWidth: 1 },
|
|
408
|
+
label: { color: '#A4B1D7' }
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
visualMap: { color: ['#bf444c','#d88273','#f6efa6'] },
|
|
412
|
+
markPoint: { label: { color: '#eee' }, emphasis: { label: { color: '#eee' } } },
|
|
413
|
+
grid: { left: '10%', right: '10%', top: 60, bottom: 60 }
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const DEFAULT_THEME = 'fui';
|
|
419
|
+
const _registeredThemes = new Set();
|
|
420
|
+
|
|
421
|
+
// Đăng ký theme với ECharts (lazy, mỗi tên chỉ một lần)
|
|
422
|
+
const _ensureTheme = (name) => {
|
|
423
|
+
const t = (_themes[name] ? name : DEFAULT_THEME);
|
|
424
|
+
if (!_registeredThemes.has(t)) {
|
|
425
|
+
echarts.registerTheme(t, _themes[t].def);
|
|
426
|
+
_registeredThemes.add(t);
|
|
427
|
+
}
|
|
428
|
+
return t;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
/* ============================================================
|
|
433
|
+
* Vue.component('f-echart')
|
|
434
|
+
* Direct mode: option prop là primary — truyền thẳng vào ECharts.
|
|
435
|
+
* config chỉ dùng cho meta/shortcut (height, theme, title, toolbox...).
|
|
436
|
+
* Bar series: tự động fill nhạt + viền màu gốc (override bằng itemStyle.color).
|
|
437
|
+
* ============================================================ */
|
|
438
|
+
Vue.component('f-echart', {
|
|
439
|
+
props: {
|
|
440
|
+
config: { type: Object, default: () => ({}) },
|
|
441
|
+
option: { type: Object, default: () => ({}) }
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
template: `
|
|
445
|
+
<div :style="{ width: '100%', height: heightStyle, position: 'relative' }">
|
|
446
|
+
<div ref="chart" style="width:100%;height:100%;"></div>
|
|
447
|
+
<div v-if="canShowDataView" class="fc-dv-btn" title="Xem dữ liệu" @click="toggleDataView">
|
|
448
|
+
<span class="mdi mdi-table"></span>
|
|
449
|
+
</div>
|
|
450
|
+
<div v-if="showDataView" class="fc-dv-overlay">
|
|
451
|
+
<div class="fc-dv-header">
|
|
452
|
+
<span>{{ dvTitle }}</span>
|
|
453
|
+
<button class="fc-dv-close" @click="showDataView = false">✕</button>
|
|
454
|
+
</div>
|
|
455
|
+
<div class="fc-dv-body">
|
|
456
|
+
<table class="fc-dv-table">
|
|
457
|
+
<thead>
|
|
458
|
+
<tr><th v-for="col in tableCols" :key="col.key">{{ col.label }}</th></tr>
|
|
459
|
+
</thead>
|
|
460
|
+
<tbody>
|
|
461
|
+
<tr v-for="(row, ri) in tableData" :key="ri">
|
|
462
|
+
<td v-for="col in tableCols" :key="col.key">{{ row[col.key] }}</td>
|
|
463
|
+
</tr>
|
|
464
|
+
</tbody>
|
|
465
|
+
</table>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
</div>`,
|
|
469
|
+
|
|
470
|
+
data() {
|
|
471
|
+
return { chartInstance: null, timer: null, showDataView: false, activeTheme: null, resizeObserver: null };
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
computed: {
|
|
475
|
+
// Note: Vue 3 reserves the "_"/"$" prefix for internal instance internals —
|
|
476
|
+
// any data/computed/method name starting with "_" referenced directly in the
|
|
477
|
+
// template (not via `this.`) throws "ReferenceError: X is not defined".
|
|
478
|
+
heightStyle() {
|
|
479
|
+
const h = this.config.height || 400;
|
|
480
|
+
return typeof h === 'number' ? h + 'px' : h;
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
tableData() {
|
|
484
|
+
if (!this.config.dataView) return [];
|
|
485
|
+
return this.extractDataRows(this.option) || [];
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
tableCols() {
|
|
489
|
+
if (!this.tableData.length) return [];
|
|
490
|
+
return Object.keys(this.tableData[0]).map(k => ({ key: k, label: k }));
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
canShowDataView() {
|
|
494
|
+
return !!this.config.dataView && this.tableData.length > 0;
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
dvTitle() {
|
|
498
|
+
const t = this.tableData.length;
|
|
499
|
+
const c = this.tableCols.length;
|
|
500
|
+
return (this.config.title || 'Dữ liệu') + ' — ' + t + ' h\xe0ng \xd7 ' + c + ' cột';
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
mounted() {
|
|
505
|
+
this.injectDvStyles();
|
|
506
|
+
const t = _ensureTheme(('theme' in this.config ? this.config.theme : null) || DEFAULT_THEME);
|
|
507
|
+
this.activeTheme = t;
|
|
508
|
+
this.chartInstance = echarts.init(this.$refs.chart, t);
|
|
509
|
+
this.bindEvents();
|
|
510
|
+
this.renderChart();
|
|
511
|
+
window.addEventListener('resize', this.resizeChart);
|
|
512
|
+
this.observeContainerResize();
|
|
513
|
+
if (this.config.refreshInterval) {
|
|
514
|
+
this.timer = setInterval(() => this.refresh(), this.config.refreshInterval);
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
// Cả hai tên hook: Vue 2 gọi beforeDestroy, Vue 3 (non-compat) gọi beforeUnmount —
|
|
519
|
+
// thiếu beforeUnmount làm rò rỉ resize-listener + không dispose chart trên Vue 3 thuần.
|
|
520
|
+
beforeDestroy() { this.teardown(); },
|
|
521
|
+
beforeUnmount() { this.teardown(); },
|
|
522
|
+
|
|
523
|
+
watch: {
|
|
524
|
+
config: {
|
|
525
|
+
deep: true,
|
|
526
|
+
handler() {
|
|
527
|
+
const t = _ensureTheme(('theme' in this.config ? this.config.theme : null) || DEFAULT_THEME);
|
|
528
|
+
if (t !== this.activeTheme) {
|
|
529
|
+
this.activeTheme = t;
|
|
530
|
+
if (this.chartInstance) this.chartInstance.dispose();
|
|
531
|
+
this.chartInstance = echarts.init(this.$refs.chart, t);
|
|
532
|
+
this.bindEvents();
|
|
533
|
+
}
|
|
534
|
+
this.renderChart();
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
option: { deep: true, handler() { this.renderChart(); } }
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
methods: {
|
|
541
|
+
renderChart() {
|
|
542
|
+
if (!this.chartInstance) return;
|
|
543
|
+
try {
|
|
544
|
+
const opt = this.buildOption();
|
|
545
|
+
this.chartInstance.setOption(opt, true);
|
|
546
|
+
this.$emit('rendered');
|
|
547
|
+
} catch (e) {
|
|
548
|
+
console.warn('[f-echart] renderChart error:', e);
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
// Assembles final ECharts option: config shortcuts as defaults, option wins
|
|
553
|
+
buildOption() {
|
|
554
|
+
const c = this.config;
|
|
555
|
+
const base = {};
|
|
556
|
+
// Ý định layout không biểu diễn được bằng chính option ECharts (neo cạnh nào,
|
|
557
|
+
// legend chiếm mấy hàng) — applyLayout đọc lại ở cuối.
|
|
558
|
+
const layoutHints = { legendAnchor: null, legendRows: c.legendRows };
|
|
559
|
+
|
|
560
|
+
// Background
|
|
561
|
+
base.backgroundColor = c.backgroundColor || 'transparent';
|
|
562
|
+
|
|
563
|
+
// Animation
|
|
564
|
+
if (c.animation === false) {
|
|
565
|
+
base.animation = false;
|
|
566
|
+
} else {
|
|
567
|
+
if (c.animationDuration != null) base.animationDuration = c.animationDuration;
|
|
568
|
+
if (c.animationEasing) base.animationEasing = c.animationEasing;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Color palette
|
|
572
|
+
if (c.color) base.color = c.color;
|
|
573
|
+
|
|
574
|
+
// Title shortcut — canh giữa mặc định, cỡ chữ vừa phải, và mở cho module tự
|
|
575
|
+
// định dạng: titleSize / titleColor / titleWeight / titleFont / titleAlign,
|
|
576
|
+
// hoặc titleStyle (object textStyle đầy đủ) thắng tất cả.
|
|
577
|
+
if (c.title || c.subtitle) {
|
|
578
|
+
const tStyle = { fontSize: c.titleSize || LAYOUT.titleFontSize, fontWeight: c.titleWeight || 'bold', fontFamily: c.titleFont || DEFAULT_FONT_FAMILY };
|
|
579
|
+
if (c.titleColor) tStyle.color = c.titleColor;
|
|
580
|
+
const sStyle = { fontSize: c.subtitleSize || LAYOUT.subtitleFontSize, fontFamily: c.titleFont || DEFAULT_FONT_FAMILY };
|
|
581
|
+
if (c.subtitleColor) sStyle.color = c.subtitleColor;
|
|
582
|
+
base.title = {
|
|
583
|
+
text: c.title || '',
|
|
584
|
+
subtext: c.subtitle || '',
|
|
585
|
+
left: c.titleAlign || c.titleLeft || 'center',
|
|
586
|
+
itemGap: LAYOUT.titleGap,
|
|
587
|
+
textStyle: _.merge(tStyle, c.titleStyle || {}),
|
|
588
|
+
subtextStyle: _.merge(sStyle, c.subtitleStyle || {})
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Legend: false = hide, object = merge with defaults.
|
|
593
|
+
// KHÔNG đặt top/bottom ở đây — vị trí dọc do applyLayout tính; chỉ ghi lại
|
|
594
|
+
// ý định "neo cạnh nào" (config.legendPosition, mặc định 'bottom' cho shortcut).
|
|
595
|
+
if (c.legend === false) {
|
|
596
|
+
base.legend = { show: false };
|
|
597
|
+
} else if (c.legend || c.legendConfig) {
|
|
598
|
+
base.legend = _.merge(
|
|
599
|
+
{ type: 'scroll', left: 'center' },
|
|
600
|
+
typeof c.legend === 'object' ? c.legend : {},
|
|
601
|
+
c.legendConfig || {}
|
|
602
|
+
);
|
|
603
|
+
layoutHints.legendAnchor = c.legendPosition || 'bottom';
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Tooltip — luôn inject để đảm bảo layout chuẩn, không bị page CSS override.
|
|
607
|
+
// Ưu tiên: tooltipFormatter > tooltipFormat > buildDefault (title canh giữa,
|
|
608
|
+
// label trái / giá trị phải, số đầy đủ formatFull).
|
|
609
|
+
// User vẫn có thể override hoàn toàn qua option.tooltip.formatter.
|
|
610
|
+
{
|
|
611
|
+
const tt = _.merge({ confine: true }, typeof c.tooltip === 'object' ? c.tooltip : {});
|
|
612
|
+
const fn = FEchartFormatter.buildTooltip(c);
|
|
613
|
+
tt.formatter = fn || FEchartFormatter.buildDefault();
|
|
614
|
+
base.tooltip = tt;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Toolbox (opt-in: false by default; dataView handled by Vue overlay, not ECharts toolbox)
|
|
618
|
+
const showToolbox = c.toolbox === true || !!c.toolboxConfig;
|
|
619
|
+
if (showToolbox) {
|
|
620
|
+
base.toolbox = _.merge(
|
|
621
|
+
{ feature: { saveAsImage: {}, restore: {} } },
|
|
622
|
+
c.toolboxConfig || {}
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// DataZoom shortcut
|
|
627
|
+
if (c.zoom) {
|
|
628
|
+
base.dataZoom = c.zoomConfig || [
|
|
629
|
+
{ type: 'inside' },
|
|
630
|
+
{ type: 'slider', height: 20 }
|
|
631
|
+
];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// option prop thắng mọi config shortcut
|
|
635
|
+
const opt = _.merge(base, this.option);
|
|
636
|
+
|
|
637
|
+
// Xếp chỗ cho từng khối rồi trả phần còn lại cho vùng vẽ (xem LAYOUT)
|
|
638
|
+
this.applyLayout(opt, layoutHints);
|
|
639
|
+
|
|
640
|
+
// Series style enhancement
|
|
641
|
+
this.enhanceSeriesStyle(opt);
|
|
642
|
+
|
|
643
|
+
return opt;
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
// Chiều cao thực tế của khối title (title + subtitle + khoảng giữa hai dòng).
|
|
647
|
+
// 0 nghĩa là không có khối nào để chừa chỗ.
|
|
648
|
+
measureTitle(title) {
|
|
649
|
+
if (!title || title.show === false) return 0;
|
|
650
|
+
const hasText = !!title.text, hasSub = !!title.subtext;
|
|
651
|
+
if (!hasText && !hasSub) return 0;
|
|
652
|
+
const fs = (title.textStyle && title.textStyle.fontSize) || LAYOUT.titleFontSize;
|
|
653
|
+
const sfs = (title.subtextStyle && title.subtextStyle.fontSize) || LAYOUT.subtitleFontSize;
|
|
654
|
+
const gap = typeof title.itemGap === 'number' ? title.itemGap : LAYOUT.titleGap;
|
|
655
|
+
const line = v => Math.round(v * LAYOUT.lineHeight);
|
|
656
|
+
return (hasText ? line(fs) : 0) + (hasSub ? gap + line(sfs) : 0);
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
// Xếp chỗ theo chiều dọc: đi từ mép trên xuống và từ mép dưới lên, mỗi khối
|
|
660
|
+
// hiện diện lấy đúng chiều cao của mình + một `gap`; phần chưa ai lấy là vùng vẽ.
|
|
661
|
+
// Giá trị do module tự khai luôn được tôn trọng — engine chỉ điền chỗ còn trống,
|
|
662
|
+
// và vẫn cộng dồn theo giá trị đó để khối sau không đè lên khối module đã đặt.
|
|
663
|
+
applyLayout(opt, hints) {
|
|
664
|
+
const P = LAYOUT.padding, G = LAYOUT.gap;
|
|
665
|
+
const title = Array.isArray(opt.title) ? opt.title[0] : opt.title;
|
|
666
|
+
const legend = Array.isArray(opt.legend) ? opt.legend[0] : opt.legend;
|
|
667
|
+
let top = P, bottom = P;
|
|
668
|
+
|
|
669
|
+
const titleH = this.measureTitle(title);
|
|
670
|
+
if (titleH) {
|
|
671
|
+
if (title.top == null) title.top = top;
|
|
672
|
+
// top dạng '%' / 'middle' / 'bottom': không quy ra px được → chỉ giữ mức tối thiểu
|
|
673
|
+
top = typeof title.top === 'number' ? title.top + titleH + G : Math.max(top, P + titleH + G);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (legend && legend.show !== false) {
|
|
677
|
+
const legendH = Math.max(1, hints.legendRows || 1) * LAYOUT.legendRowHeight;
|
|
678
|
+
const anchor = legend.top != null ? 'top'
|
|
679
|
+
: legend.bottom != null ? 'bottom'
|
|
680
|
+
: (hints.legendAnchor || 'top');
|
|
681
|
+
if (anchor === 'top') {
|
|
682
|
+
if (legend.top == null) legend.top = top;
|
|
683
|
+
top = typeof legend.top === 'number' ? legend.top + legendH + G : Math.max(top, P + legendH + G);
|
|
684
|
+
} else {
|
|
685
|
+
if (legend.bottom == null) legend.bottom = bottom;
|
|
686
|
+
bottom = typeof legend.bottom === 'number' ? legend.bottom + legendH + G : Math.max(bottom, P + legendH + G);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
this.reservePlotArea(opt, top, bottom);
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
// Trả phần còn lại cho vùng vẽ. Hai loại chart cần hai cơ chế khác nhau:
|
|
694
|
+
// - có hệ trục → `grid` (nhãn trục hoành vẽ NGOÀI grid nên phải chừa thêm ở dưới)
|
|
695
|
+
// - không trục → pie/funnel/… bỏ qua grid hoàn toàn, phải đặt box trên chính series,
|
|
696
|
+
// nếu không thì title/legend nằm đè lên hình (đúng ca funnel/pie đã gặp)
|
|
697
|
+
reservePlotArea(opt, top, bottom) {
|
|
698
|
+
const declared = this.option || {};
|
|
699
|
+
if (opt.xAxis || opt.yAxis) {
|
|
700
|
+
if (Array.isArray(opt.grid)) return; // multi-grid: module tự lo bố cục
|
|
701
|
+
const dg = _.isPlainObject(declared.grid) ? declared.grid : {};
|
|
702
|
+
const g = _.isPlainObject(opt.grid) ? opt.grid : (opt.grid = {});
|
|
703
|
+
if (dg.top == null) g.top = Math.max(LAYOUT.gridTop, top + LAYOUT.axisTopBand);
|
|
704
|
+
if (dg.bottom == null) g.bottom = Math.max(LAYOUT.gridBottom, bottom + LAYOUT.axisLabelBand);
|
|
705
|
+
if (dg.left == null) g.left = LAYOUT.gridLeft;
|
|
706
|
+
if (dg.right == null) g.right = LAYOUT.gridRight;
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const BOX_TYPES = ['pie', 'funnel', 'treemap', 'sankey', 'graph'];
|
|
711
|
+
const series = Array.isArray(opt.series) ? opt.series : (opt.series ? [opt.series] : []);
|
|
712
|
+
series.forEach(s => {
|
|
713
|
+
if (!s || BOX_TYPES.indexOf(s.type) < 0) return;
|
|
714
|
+
// center bằng px thì nó neo theo gốc box — đổi box sẽ dịch hình đi; để yên
|
|
715
|
+
if (Array.isArray(s.center) && s.center.some(v => typeof v === 'number')) return;
|
|
716
|
+
if (s.top == null) s.top = top;
|
|
717
|
+
if (s.bottom == null) s.bottom = bottom;
|
|
718
|
+
});
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
// Đọc enhance policy từ theme → áp style thống nhất cho các series type có dạng "khối".
|
|
722
|
+
// Policy được định nghĩa trong _themes[t].enhance — mỗi theme tự quyết lightenAmount, color, v.v.
|
|
723
|
+
enhanceSeriesStyle(opt) {
|
|
724
|
+
const t = (this.activeTheme && _themes[this.activeTheme]) ? this.activeTheme : DEFAULT_THEME;
|
|
725
|
+
const palette = (opt.color && opt.color.length) ? opt.color : _themes[t].colors;
|
|
726
|
+
const e = _themes[t].enhance || {};
|
|
727
|
+
const lightenSet = new Set(e.lighten || []);
|
|
728
|
+
const separatorSet = new Set(e.separator || []);
|
|
729
|
+
const outlineSet = new Set(e.outline || []);
|
|
730
|
+
const amount = e.lightenAmount ?? 0.5;
|
|
731
|
+
const sepColor = e.separatorColor ?? 'rgba(255,255,255,0.75)';
|
|
732
|
+
const outColor = e.outlineColor ?? 'rgba(255,255,255,0.8)';
|
|
733
|
+
const outWidth = e.outlineWidth ?? 1.5;
|
|
734
|
+
|
|
735
|
+
let idx = 0;
|
|
736
|
+
(opt.series || []).forEach(s => {
|
|
737
|
+
if (!s) { idx++; return; }
|
|
738
|
+
|
|
739
|
+
if (lightenSet.has(s.type)) {
|
|
740
|
+
if (s.type === 'bar') {
|
|
741
|
+
// Bar: per-series — một màu duy nhất cho toàn bộ series
|
|
742
|
+
const hasExplicit = s.itemStyle && s.itemStyle.color != null;
|
|
743
|
+
if (!hasExplicit) {
|
|
744
|
+
const orig = palette[idx % palette.length];
|
|
745
|
+
s.itemStyle = _.merge({ color: _lightenHex(orig, amount), borderColor: orig, borderWidth: 1 }, s.itemStyle || {});
|
|
746
|
+
}
|
|
747
|
+
} else if (Array.isArray(s.data)) {
|
|
748
|
+
// Pie / funnel: per-item — mỗi lát/phần tử lấy màu riêng từ palette
|
|
749
|
+
s.data.forEach((item, i) => {
|
|
750
|
+
if (!item || typeof item !== 'object') return;
|
|
751
|
+
if (!item.itemStyle) item.itemStyle = {};
|
|
752
|
+
if (item.itemStyle.color == null) {
|
|
753
|
+
const orig = palette[i % palette.length];
|
|
754
|
+
item.itemStyle.color = _lightenHex(orig, amount);
|
|
755
|
+
item.itemStyle.borderColor = orig;
|
|
756
|
+
item.itemStyle.borderWidth = 1;
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
} else if (separatorSet.has(s.type)) {
|
|
761
|
+
// Treemap / sunburst: viền trắng phân cách giữa các ô/lớp
|
|
762
|
+
if (!s.itemStyle) s.itemStyle = {};
|
|
763
|
+
if (s.itemStyle.borderWidth == null) s.itemStyle.borderWidth = 1;
|
|
764
|
+
if (s.itemStyle.borderColor == null) s.itemStyle.borderColor = sepColor;
|
|
765
|
+
} else if (outlineSet.has(s.type)) {
|
|
766
|
+
// Scatter / effectScatter: viền trắng quanh điểm để tách biệt khi chồng lên nhau
|
|
767
|
+
if (!s.itemStyle) s.itemStyle = {};
|
|
768
|
+
if (s.itemStyle.borderWidth == null) s.itemStyle.borderWidth = outWidth;
|
|
769
|
+
if (s.itemStyle.borderColor == null) s.itemStyle.borderColor = outColor;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
idx++;
|
|
773
|
+
});
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
// Trích dữ liệu dạng [{col: val}] từ ECharts option để hiển thị DataView table.
|
|
777
|
+
// Hỗ trợ: dataset.source (object-array hoặc array-of-arrays) và series[].data + xAxis.data.
|
|
778
|
+
extractDataRows(opt) {
|
|
779
|
+
// Case 1: dataset.source
|
|
780
|
+
const ds = Array.isArray(opt.dataset) ? opt.dataset[0] : opt.dataset;
|
|
781
|
+
if (ds && ds.source && ds.source.length) {
|
|
782
|
+
const src = ds.source;
|
|
783
|
+
// Object-array: [{field: val}] → dùng trực tiếp
|
|
784
|
+
if (typeof src[0] === 'object' && !Array.isArray(src[0])) return src;
|
|
785
|
+
// Array-of-arrays: first row = headers → convert
|
|
786
|
+
if (Array.isArray(src[0])) {
|
|
787
|
+
const [headers, ...rows] = src;
|
|
788
|
+
return rows.map(r => Object.fromEntries(headers.map((h, i) => [h, r[i]])));
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Case 2: series[].data + category axis
|
|
793
|
+
const series = (opt.series || []).filter(s => s && Array.isArray(s.data));
|
|
794
|
+
if (!series.length) return null;
|
|
795
|
+
const categories = (Array.isArray(opt.xAxis) ? opt.xAxis[0] : opt.xAxis)?.data
|
|
796
|
+
|| (Array.isArray(opt.yAxis) ? opt.yAxis[0] : opt.yAxis)?.data
|
|
797
|
+
|| [];
|
|
798
|
+
if (categories.length) {
|
|
799
|
+
return categories.map((cat, i) => {
|
|
800
|
+
const row = { Danh_mục: cat };
|
|
801
|
+
series.forEach(s => {
|
|
802
|
+
const v = s.data[i];
|
|
803
|
+
row[s.name || 'Giá trị'] = (v != null && typeof v === 'object') ? (v.value ?? v) : v;
|
|
804
|
+
});
|
|
805
|
+
return row;
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Case 3: single series, no category — dùng index làm key
|
|
810
|
+
if (series.length === 1 && series[0].data.length) {
|
|
811
|
+
return series[0].data.map((v, i) => ({
|
|
812
|
+
STT: i + 1,
|
|
813
|
+
[series[0].name || 'Giá trị']: (typeof v === 'object' && v != null) ? (v.value ?? v) : v
|
|
814
|
+
}));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return null;
|
|
818
|
+
},
|
|
819
|
+
|
|
820
|
+
toggleDataView() { this.showDataView = !this.showDataView; },
|
|
821
|
+
|
|
822
|
+
injectDvStyles() {
|
|
823
|
+
if (document.getElementById('fc-dv-style')) return;
|
|
824
|
+
const s = document.createElement('style');
|
|
825
|
+
s.id = 'fc-dv-style';
|
|
826
|
+
s.textContent = [
|
|
827
|
+
'.fc-dv-btn{position:absolute;top:6px;right:6px;z-index:1;width:26px;height:26px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.85);border:1px solid #ddd;border-radius:4px;cursor:pointer;font-size:16px;color:#555;}',
|
|
828
|
+
'.fc-dv-btn:hover{background:#fff;color:#333;border-color:#aaa;}',
|
|
829
|
+
'.fc-dv-overlay{position:absolute;inset:0;z-index:1;background:#fff;display:flex;flex-direction:column;border:1px solid #e0e0e0;border-radius:4px;overflow:hidden;}',
|
|
830
|
+
'.fc-dv-header{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:#f5f5f5;border-bottom:1px solid #ddd;font-size:12px;font-weight:600;color:#444;flex-shrink:0;}',
|
|
831
|
+
'.fc-dv-close{background:none;border:none;font-size:14px;cursor:pointer;color:#666;padding:0 4px;line-height:1;}',
|
|
832
|
+
'.fc-dv-close:hover{color:#d32f2f;}',
|
|
833
|
+
'.fc-dv-body{overflow:auto;flex:1;}',
|
|
834
|
+
'.fc-dv-table{width:100%;border-collapse:collapse;font-size:12px;}',
|
|
835
|
+
'.fc-dv-table th{position:sticky;top:0;background:#f9f9f9;border:1px solid #e0e0e0;padding:5px 8px;text-align:left;font-weight:600;white-space:nowrap;}',
|
|
836
|
+
'.fc-dv-table td{border:1px solid #f0f0f0;padding:4px 8px;white-space:nowrap;text-align:left;}',
|
|
837
|
+
'.fc-dv-table tr:nth-child(even) td{background:#fafafa;}',
|
|
838
|
+
'.fc-dv-table tr:hover td{background:#e8f4fd;}'
|
|
839
|
+
].join('');
|
|
840
|
+
document.head.appendChild(s);
|
|
841
|
+
},
|
|
842
|
+
|
|
843
|
+
// window 'resize' only fires on viewport changes; it misses container-size
|
|
844
|
+
// changes caused by layout settling after mount (Vuetify grid/flex finishing
|
|
845
|
+
// its pass, a dialog/tab becoming visible, sidebar collapse, etc.) — the exact
|
|
846
|
+
// gap that leaves the canvas permanently sized to a stale mount-time width.
|
|
847
|
+
// ResizeObserver watches the element itself and fires on any of those too.
|
|
848
|
+
observeContainerResize() {
|
|
849
|
+
if (typeof ResizeObserver === 'undefined') return; // old browser: window-resize fallback only
|
|
850
|
+
let raf = null;
|
|
851
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
852
|
+
if (raf) cancelAnimationFrame(raf);
|
|
853
|
+
raf = requestAnimationFrame(() => this.resizeChart());
|
|
854
|
+
});
|
|
855
|
+
this.resizeObserver.observe(this.$el);
|
|
856
|
+
},
|
|
857
|
+
|
|
858
|
+
teardown() {
|
|
859
|
+
window.removeEventListener('resize', this.resizeChart);
|
|
860
|
+
if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; }
|
|
861
|
+
if (this.timer) clearInterval(this.timer);
|
|
862
|
+
if (this.chartInstance) this.chartInstance.dispose();
|
|
863
|
+
},
|
|
864
|
+
|
|
865
|
+
refresh() { this.renderChart(); },
|
|
866
|
+
resizeChart() { if (this.chartInstance) this.chartInstance.resize(); },
|
|
867
|
+
getInstance() { return this.chartInstance; },
|
|
868
|
+
|
|
869
|
+
exportImage(name = 'chart.png') {
|
|
870
|
+
if (!this.chartInstance) return;
|
|
871
|
+
const a = document.createElement('a');
|
|
872
|
+
a.href = this.chartInstance.getDataURL({ pixelRatio: 2, backgroundColor: '#fff' });
|
|
873
|
+
a.download = name;
|
|
874
|
+
a.click();
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
bindEvents() {
|
|
878
|
+
this.chartInstance.on('click', p => {
|
|
879
|
+
this.$emit('chart-click', p);
|
|
880
|
+
this.$emit('drilldown', p);
|
|
881
|
+
});
|
|
882
|
+
this.chartInstance.on('legendselectchanged', p => {
|
|
883
|
+
this.$emit('legend-change', p);
|
|
884
|
+
});
|
|
885
|
+
this.chartInstance.on('finished', () => {
|
|
886
|
+
this.$emit('finished');
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
});
|