@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.
Files changed (212) hide show
  1. package/README.md +18 -3
  2. package/dist/fui.js +160 -43
  3. package/package.json +4 -2
  4. package/skills/fui/SKILL.md +131 -66
  5. package/skills/fui-skill/README.md +112 -0
  6. package/skills/fui-skill/SKILL.md +269 -0
  7. package/skills/fui-skill/assets/projectdefaultstyle.css +518 -0
  8. package/skills/fui-skill/design-md/airbnb/DESIGN.md +545 -0
  9. package/skills/fui-skill/design-md/airbnb/README.md +5 -0
  10. package/skills/fui-skill/design-md/airtable/DESIGN.md +554 -0
  11. package/skills/fui-skill/design-md/airtable/README.md +5 -0
  12. package/skills/fui-skill/design-md/apple/DESIGN.md +562 -0
  13. package/skills/fui-skill/design-md/apple/README.md +5 -0
  14. package/skills/fui-skill/design-md/asu/DESIGN.md +179 -0
  15. package/skills/fui-skill/design-md/asu/README.md +107 -0
  16. package/skills/fui-skill/design-md/binance/DESIGN.md +634 -0
  17. package/skills/fui-skill/design-md/binance/README.md +5 -0
  18. package/skills/fui-skill/design-md/bmw/DESIGN.md +544 -0
  19. package/skills/fui-skill/design-md/bmw/README.md +5 -0
  20. package/skills/fui-skill/design-md/bmw-m/DESIGN.md +503 -0
  21. package/skills/fui-skill/design-md/bmw-m/README.md +5 -0
  22. package/skills/fui-skill/design-md/bugatti/DESIGN.md +454 -0
  23. package/skills/fui-skill/design-md/bugatti/README.md +5 -0
  24. package/skills/fui-skill/design-md/cal/DESIGN.md +542 -0
  25. package/skills/fui-skill/design-md/cal/README.md +5 -0
  26. package/skills/fui-skill/design-md/claude/DESIGN.md +589 -0
  27. package/skills/fui-skill/design-md/claude/README.md +5 -0
  28. package/skills/fui-skill/design-md/clay/DESIGN.md +541 -0
  29. package/skills/fui-skill/design-md/clay/README.md +5 -0
  30. package/skills/fui-skill/design-md/clickhouse/DESIGN.md +544 -0
  31. package/skills/fui-skill/design-md/clickhouse/README.md +5 -0
  32. package/skills/fui-skill/design-md/cohere/DESIGN.md +451 -0
  33. package/skills/fui-skill/design-md/cohere/README.md +5 -0
  34. package/skills/fui-skill/design-md/coinbase/DESIGN.md +570 -0
  35. package/skills/fui-skill/design-md/coinbase/README.md +5 -0
  36. package/skills/fui-skill/design-md/composio/DESIGN.md +506 -0
  37. package/skills/fui-skill/design-md/composio/README.md +5 -0
  38. package/skills/fui-skill/design-md/cursor/DESIGN.md +537 -0
  39. package/skills/fui-skill/design-md/cursor/README.md +5 -0
  40. package/skills/fui-skill/design-md/elevenlabs/DESIGN.md +504 -0
  41. package/skills/fui-skill/design-md/elevenlabs/README.md +5 -0
  42. package/skills/fui-skill/design-md/expo/DESIGN.md +526 -0
  43. package/skills/fui-skill/design-md/expo/README.md +5 -0
  44. package/skills/fui-skill/design-md/ferrari/DESIGN.md +531 -0
  45. package/skills/fui-skill/design-md/ferrari/README.md +5 -0
  46. package/skills/fui-skill/design-md/figma/DESIGN.md +578 -0
  47. package/skills/fui-skill/design-md/figma/README.md +5 -0
  48. package/skills/fui-skill/design-md/framer/DESIGN.md +544 -0
  49. package/skills/fui-skill/design-md/framer/README.md +5 -0
  50. package/skills/fui-skill/design-md/fui/DESIGN.md +532 -0
  51. package/skills/fui-skill/design-md/hashicorp/DESIGN.md +575 -0
  52. package/skills/fui-skill/design-md/hashicorp/README.md +5 -0
  53. package/skills/fui-skill/design-md/ibm/DESIGN.md +550 -0
  54. package/skills/fui-skill/design-md/ibm/README.md +5 -0
  55. package/skills/fui-skill/design-md/intercom/DESIGN.md +546 -0
  56. package/skills/fui-skill/design-md/intercom/README.md +5 -0
  57. package/skills/fui-skill/design-md/kraken/DESIGN.md +125 -0
  58. package/skills/fui-skill/design-md/kraken/README.md +5 -0
  59. package/skills/fui-skill/design-md/lamborghini/DESIGN.md +288 -0
  60. package/skills/fui-skill/design-md/lamborghini/README.md +5 -0
  61. package/skills/fui-skill/design-md/linear.app/DESIGN.md +548 -0
  62. package/skills/fui-skill/design-md/linear.app/README.md +5 -0
  63. package/skills/fui-skill/design-md/lovable/DESIGN.md +298 -0
  64. package/skills/fui-skill/design-md/lovable/README.md +5 -0
  65. package/skills/fui-skill/design-md/mastercard/DESIGN.md +365 -0
  66. package/skills/fui-skill/design-md/mastercard/README.md +5 -0
  67. package/skills/fui-skill/design-md/meta/DESIGN.md +683 -0
  68. package/skills/fui-skill/design-md/meta/README.md +5 -0
  69. package/skills/fui-skill/design-md/minimax/DESIGN.md +746 -0
  70. package/skills/fui-skill/design-md/minimax/README.md +5 -0
  71. package/skills/fui-skill/design-md/mintlify/DESIGN.md +852 -0
  72. package/skills/fui-skill/design-md/mintlify/README.md +5 -0
  73. package/skills/fui-skill/design-md/miro/DESIGN.md +825 -0
  74. package/skills/fui-skill/design-md/miro/README.md +5 -0
  75. package/skills/fui-skill/design-md/mistral.ai/DESIGN.md +773 -0
  76. package/skills/fui-skill/design-md/mistral.ai/README.md +5 -0
  77. package/skills/fui-skill/design-md/mongodb/DESIGN.md +767 -0
  78. package/skills/fui-skill/design-md/mongodb/README.md +5 -0
  79. package/skills/fui-skill/design-md/nike/DESIGN.md +575 -0
  80. package/skills/fui-skill/design-md/nike/README.md +5 -0
  81. package/skills/fui-skill/design-md/notion/DESIGN.md +821 -0
  82. package/skills/fui-skill/design-md/notion/README.md +5 -0
  83. package/skills/fui-skill/design-md/nvidia/DESIGN.md +640 -0
  84. package/skills/fui-skill/design-md/nvidia/README.md +5 -0
  85. package/skills/fui-skill/design-md/ollama/DESIGN.md +539 -0
  86. package/skills/fui-skill/design-md/ollama/README.md +5 -0
  87. package/skills/fui-skill/design-md/opencode.ai/DESIGN.md +521 -0
  88. package/skills/fui-skill/design-md/opencode.ai/README.md +5 -0
  89. package/skills/fui-skill/design-md/pinterest/DESIGN.md +597 -0
  90. package/skills/fui-skill/design-md/pinterest/README.md +5 -0
  91. package/skills/fui-skill/design-md/playstation/DESIGN.md +661 -0
  92. package/skills/fui-skill/design-md/playstation/README.md +5 -0
  93. package/skills/fui-skill/design-md/posthog/DESIGN.md +690 -0
  94. package/skills/fui-skill/design-md/posthog/README.md +5 -0
  95. package/skills/fui-skill/design-md/raycast/DESIGN.md +669 -0
  96. package/skills/fui-skill/design-md/raycast/README.md +5 -0
  97. package/skills/fui-skill/design-md/renault/DESIGN.md +589 -0
  98. package/skills/fui-skill/design-md/renault/README.md +5 -0
  99. package/skills/fui-skill/design-md/replicate/DESIGN.md +616 -0
  100. package/skills/fui-skill/design-md/replicate/README.md +5 -0
  101. package/skills/fui-skill/design-md/resend/DESIGN.md +585 -0
  102. package/skills/fui-skill/design-md/resend/README.md +5 -0
  103. package/skills/fui-skill/design-md/revolut/DESIGN.md +636 -0
  104. package/skills/fui-skill/design-md/revolut/README.md +5 -0
  105. package/skills/fui-skill/design-md/runwayml/DESIGN.md +244 -0
  106. package/skills/fui-skill/design-md/runwayml/README.md +5 -0
  107. package/skills/fui-skill/design-md/sanity/DESIGN.md +357 -0
  108. package/skills/fui-skill/design-md/sanity/README.md +5 -0
  109. package/skills/fui-skill/design-md/sentry/DESIGN.md +551 -0
  110. package/skills/fui-skill/design-md/sentry/README.md +5 -0
  111. package/skills/fui-skill/design-md/shopify/DESIGN.md +516 -0
  112. package/skills/fui-skill/design-md/shopify/README.md +5 -0
  113. package/skills/fui-skill/design-md/slack/DESIGN.md +482 -0
  114. package/skills/fui-skill/design-md/spacex/DESIGN.md +363 -0
  115. package/skills/fui-skill/design-md/spacex/README.md +5 -0
  116. package/skills/fui-skill/design-md/spotify/DESIGN.md +246 -0
  117. package/skills/fui-skill/design-md/spotify/README.md +5 -0
  118. package/skills/fui-skill/design-md/starbucks/DESIGN.md +580 -0
  119. package/skills/fui-skill/design-md/starbucks/README.md +5 -0
  120. package/skills/fui-skill/design-md/stripe/DESIGN.md +487 -0
  121. package/skills/fui-skill/design-md/stripe/README.md +5 -0
  122. package/skills/fui-skill/design-md/supabase/DESIGN.md +462 -0
  123. package/skills/fui-skill/design-md/supabase/README.md +5 -0
  124. package/skills/fui-skill/design-md/superhuman/DESIGN.md +448 -0
  125. package/skills/fui-skill/design-md/superhuman/README.md +5 -0
  126. package/skills/fui-skill/design-md/tesla/DESIGN.md +286 -0
  127. package/skills/fui-skill/design-md/tesla/README.md +5 -0
  128. package/skills/fui-skill/design-md/theverge/DESIGN.md +339 -0
  129. package/skills/fui-skill/design-md/theverge/README.md +5 -0
  130. package/skills/fui-skill/design-md/together.ai/DESIGN.md +633 -0
  131. package/skills/fui-skill/design-md/together.ai/README.md +5 -0
  132. package/skills/fui-skill/design-md/uber/DESIGN.md +636 -0
  133. package/skills/fui-skill/design-md/uber/README.md +5 -0
  134. package/skills/fui-skill/design-md/vercel/DESIGN.md +736 -0
  135. package/skills/fui-skill/design-md/vercel/README.md +5 -0
  136. package/skills/fui-skill/design-md/vodafone/DESIGN.md +538 -0
  137. package/skills/fui-skill/design-md/vodafone/README.md +5 -0
  138. package/skills/fui-skill/design-md/voltagent/DESIGN.md +521 -0
  139. package/skills/fui-skill/design-md/voltagent/README.md +5 -0
  140. package/skills/fui-skill/design-md/warp/DESIGN.md +526 -0
  141. package/skills/fui-skill/design-md/warp/README.md +5 -0
  142. package/skills/fui-skill/design-md/webflow/DESIGN.md +588 -0
  143. package/skills/fui-skill/design-md/webflow/README.md +5 -0
  144. package/skills/fui-skill/design-md/wired/DESIGN.md +497 -0
  145. package/skills/fui-skill/design-md/wired/README.md +5 -0
  146. package/skills/fui-skill/design-md/wise/DESIGN.md +544 -0
  147. package/skills/fui-skill/design-md/wise/README.md +5 -0
  148. package/skills/fui-skill/design-md/x.ai/DESIGN.md +465 -0
  149. package/skills/fui-skill/design-md/x.ai/README.md +5 -0
  150. package/skills/fui-skill/design-md/zapier/DESIGN.md +537 -0
  151. package/skills/fui-skill/design-md/zapier/README.md +5 -0
  152. package/skills/fui-skill/examples/component.vue +162 -0
  153. package/skills/fui-skill/examples/f-table-patterns.json +331 -0
  154. package/skills/fui-skill/examples/module-patterns.json +973 -0
  155. package/skills/fui-skill/examples/project-patterns.json +222 -0
  156. package/skills/fui-skill/metadata.json +75 -0
  157. package/skills/fui-skill/references/INDEX.md +144 -0
  158. package/skills/fui-skill/references/advanced-techniques.md +160 -0
  159. package/skills/fui-skill/references/coding-standards.md +112 -0
  160. package/skills/fui-skill/references/component-design.md +455 -0
  161. package/skills/fui-skill/references/component-quickref.md +77 -0
  162. package/skills/fui-skill/references/component-table.md +276 -0
  163. package/skills/fui-skill/references/components-dialog.md +192 -0
  164. package/skills/fui-skill/references/components-display.md +147 -0
  165. package/skills/fui-skill/references/components-echart.md +391 -0
  166. package/skills/fui-skill/references/components-input.md +359 -0
  167. package/skills/fui-skill/references/controls-patterns.md +847 -0
  168. package/skills/fui-skill/references/controls-styling-vocabulary.md +140 -0
  169. package/skills/fui-skill/references/db-table-design.md +77 -0
  170. package/skills/fui-skill/references/db-workflow.md +504 -0
  171. package/skills/fui-skill/references/default-function.md +415 -0
  172. package/skills/fui-skill/references/design-modes.md +85 -0
  173. package/skills/fui-skill/references/echart-templates.md +481 -0
  174. package/skills/fui-skill/references/fastproject.md +97 -0
  175. package/skills/fui-skill/references/fsheet.md +218 -0
  176. package/skills/fui-skill/references/fullstack-workflow.md +351 -0
  177. package/skills/fui-skill/references/module-data-patterns.md +126 -0
  178. package/skills/fui-skill/references/module-json-anatomy.md +137 -0
  179. package/skills/fui-skill/references/module-structure.md +260 -0
  180. package/skills/fui-skill/references/new-session.md +108 -0
  181. package/skills/fui-skill/references/pdfmake.md +60 -0
  182. package/skills/fui-skill/references/permission-system.md +169 -0
  183. package/skills/fui-skill/references/platform-architecture.md +294 -0
  184. package/skills/fui-skill/references/project-config.md +335 -0
  185. package/skills/fui-skill/references/project-provisioning.md +383 -0
  186. package/skills/fui-skill/references/script-map.md +296 -0
  187. package/skills/fui-skill/references/sql-clr-functions.md +224 -0
  188. package/skills/fui-skill/references/system-design.md +116 -0
  189. package/skills/fui-skill/references/tapi-file-api.md +191 -0
  190. package/skills/fui-skill/references/tapi-permission-patterns.md +158 -0
  191. package/skills/fui-skill/references/tapi-reference.md +549 -0
  192. package/skills/fui-skill/references/tools-registry.md +460 -0
  193. package/skills/fui-skill/references/ui-crosswindow-patterns.md +317 -0
  194. package/skills/fui-skill/references/ui-dialog-patterns.md +229 -0
  195. package/skills/fui-skill/references/ui-layout-patterns.md +176 -0
  196. package/skills/fui-skill/references/ui-patterns.md +315 -0
  197. package/skills/fui-skill/references/ui-screenshot-review.md +94 -0
  198. package/skills/fui-skill/references/ui-table-cell-patterns.md +316 -0
  199. package/skills/fui-skill/references/ui-templates.md +29 -0
  200. package/skills/fui-skill/references/verification.md +246 -0
  201. package/skills/fui-skill/references/watcher-patterns.md +196 -0
  202. package/skills/fui-skill/references/websocket-realtime.md +254 -0
  203. package/skills/fui-skill/scripts/component-3.0.js +2549 -0
  204. package/skills/fui-skill/scripts/component.js +3142 -0
  205. package/skills/fui-skill/scripts/componentTable-3.0.js +909 -0
  206. package/skills/fui-skill/scripts/componentTable.js +769 -0
  207. package/skills/fui-skill/scripts/defaultfunction-3.0.js +781 -0
  208. package/skills/fui-skill/scripts/defaultfunction.js +966 -0
  209. package/skills/fui-skill/scripts/fastproject-3.0.js +870 -0
  210. package/skills/fui-skill/scripts/fastproject.js +828 -0
  211. package/skills/fui-skill/scripts/fechart.js +890 -0
  212. 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">&#x2715;</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
+ });