ChartSpecial.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. <template>
  2. <div class="middle-wrapper">
  3. <div class="building-chart">
  4. <SectionHeader
  5. layout="spread"
  6. title="各集团建筑面积排名(万m²)"
  7. />
  8. <div class="building-bar-chart">
  9. <BarChart
  10. clickable
  11. :style="isSmallScreen ? smallMainBarStyle : 'width: 100%; height: 100%; min-height: 320px'"
  12. :xAxisData="xAxisDataList"
  13. :seriesData="seriesDataList"
  14. @bar-click="onMainBarClick"
  15. />
  16. </div>
  17. </div>
  18. <div class="middle-group">
  19. <div class="middle-group-top">
  20. <div v-for="item in pieChartList" :key="item.name" class="chart-card">
  21. <SectionHeader :title="item.text" />
  22. <BarChart
  23. v-if="isSmallScreen"
  24. :xAxisData="pieToBar(item.data).xAxisData"
  25. :seriesData="pieToBar(item.data).seriesData"
  26. :style="smallBarStyle"
  27. />
  28. <PieChart
  29. v-else
  30. :data="item.data"
  31. :legend-head-count="item.legendHeadCount"
  32. :legend-grid-no-padding-right="item.legendGridNoPaddingRight"
  33. value-unit="万m²"
  34. />
  35. </div>
  36. </div>
  37. <div class="middle-group-bottom">
  38. <TopicCard
  39. v-for="item in topicCardList"
  40. :key="item.name"
  41. :text="item.text"
  42. :introduction="item.introduction"
  43. :route="item.route"
  44. />
  45. </div>
  46. </div>
  47. </div>
  48. </template>
  49. <script setup>
  50. import BarChart from '@/components/chart/BarChart.vue';
  51. import SectionHeader from '@/components/base/SectionHeader.vue';
  52. import TopicCard from '@/components/stats/TopicCard.vue';
  53. import PieChart from '@/components/chart/PieChart.vue';
  54. import { ref, onMounted, onUnmounted, computed } from 'vue';
  55. import { useRouter } from 'vue-router';
  56. import { useEnterpriseStore } from '@/store/enterprise';
  57. import { useFilterStore } from '@/store/filter';
  58. import { sumFieldToWan,toWanSquareMeters } from '@/utils/areaMath';
  59. const router = useRouter();
  60. const filterStore = useFilterStore();
  61. const enterpriseStore = useEnterpriseStore();
  62. const mq = window.matchMedia('(max-width: 1440px)');
  63. const isSmallScreen = ref(mq.matches);
  64. const smallBarStyle = 'width: 100%; height: 218px';
  65. const smallMainBarStyle = 'width: 100%; height: 393px';
  66. const onBreakpointChange = (e) => {
  67. isSmallScreen.value = e.matches;
  68. };
  69. const pieToBar = (pieData) => {
  70. const data = pieData ?? [];
  71. return {
  72. xAxisData: data.map((item) => item.name),
  73. seriesData: data.map((item) => item.value),
  74. };
  75. };
  76. /** 首页主柱图点击:按集团筛选并跳转专题一 */
  77. const onMainBarClick = (groupName) => {
  78. filterStore.setGroup(groupName);
  79. router.push('/topic1');
  80. };
  81. const filteredList = computed(() =>
  82. enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
  83. );
  84. //柱状图数据
  85. const groupChartData = computed(() => {
  86. const list = enterpriseStore.filteredByGroup('全部', filterStore.selectDate);
  87. const groupMap = list.reduce((acc, item) => {
  88. const name = (item.c_ssqpjt ?? '其他').trim()
  89. acc[name] = (acc[name] ?? 0) + Number(item.c_sjjzmj || 0)
  90. return acc
  91. }, {})
  92. return Object.entries(groupMap).map(([name, val]) => ({
  93. name,
  94. value: toWanSquareMeters(val),
  95. }))
  96. })
  97. const xAxisDataList = computed(()=>{
  98. return groupChartData.value.map(item => item.name)
  99. })
  100. const seriesDataList = computed(()=>{
  101. return groupChartData.value.map(item => item.value)
  102. })
  103. //使用状态分布环形图数据
  104. const USAGE_PIE_CONFIG = [
  105. { name: '出租', field: 'c_czmj', color: 'rgba(1, 118, 255, 1)' },
  106. { name: '自用', field: 'c_zymj', color: 'rgba(255, 121, 98, 1)' },
  107. { name: '出借', field: 'c_cjmj', color: 'rgba(104, 221, 169, 1)' },
  108. { name: '闲置', field: 'c_xzmj', color: 'rgba(255, 232, 119, 1)' },
  109. ]
  110. const usageStatusList = computed(() =>
  111. USAGE_PIE_CONFIG.map(({ name, field, color }) => ({
  112. name,
  113. color,
  114. value: sumFieldToWan(filteredList.value, field),
  115. }))
  116. )
  117. //房龄分布环形图数据
  118. const ageDistributionList = computed(() => {
  119. const origin = filteredList.value || []
  120. const groupConfig = [
  121. {
  122. name: '0-10年',
  123. color: 'rgba(1, 118, 255, 1)',
  124. filter: val => Number(val) <= 10
  125. },
  126. {
  127. name: '10-20年',
  128. color: 'rgba(255, 121, 98, 1)',
  129. filter: val => Number(val) > 10 && Number(val) <= 20
  130. },
  131. {
  132. name: '20年以上',
  133. color: 'rgba(104, 221, 169, 1)',
  134. filter: val => (Number(val) > 20) || val === '30年以上'
  135. },
  136. {
  137. name: '不确定',
  138. color: 'rgba(255, 232, 119, 1)',
  139. filter: () => true
  140. }
  141. ]
  142. const result = groupConfig.map(item => ({
  143. name: item.name,
  144. value: 0,
  145. color: item.color
  146. }))
  147. origin.forEach(item => {
  148. const fieldVal = item.c_fl
  149. const target = result.find((_, idx) => groupConfig[idx].filter(fieldVal))
  150. if(target) {
  151. const area = Number(item.c_sjjzmj || 0)
  152. target.value += area
  153. }
  154. })
  155. return result
  156. .map(item => {
  157. const wan = Number((item.value / 10000).toFixed(2))
  158. return { ...item, value: wan }
  159. })
  160. .filter(item => item.value > 0)
  161. })
  162. //按用途分类建筑面积(万m²)
  163. const hashString = (str) => {
  164. let hash = 0
  165. for (let i = 0; i < str.length; i++) {
  166. hash = (hash * 31 + str.charCodeAt(i)) >>> 0
  167. }
  168. return hash
  169. }
  170. const getColorByName = (name) => {
  171. const hash = hashString(name)
  172. const GOLDEN_ANGLE = 137.508
  173. const hue = (hash * GOLDEN_ANGLE) % 360
  174. const saturation = 58 + (hash % 4) * 9
  175. const lightness = 44 + ((hash >> 3) % 5) * 5
  176. return `hsla(${Math.round(hue)}, ${saturation}%, ${lightness}%, 1)`
  177. }
  178. const PURPOSE_TOP_N = 5
  179. const EMPTY_LABEL = '未知'
  180. const OTHER_LABEL = '其他'
  181. const OTHER_COLOR = 'rgba(178, 190, 195, 1)'
  182. const purposeListFull = computed(() => {
  183. const purposeMap = (filteredList.value ?? []).reduce((acc, item) => {
  184. const name = (item.c_tdqk_yt1 ?? EMPTY_LABEL).trim() || EMPTY_LABEL;
  185. acc[name] = (acc[name] ?? 0) + Number(item.c_zdmj || 0);
  186. return acc;
  187. }, {});
  188. return Object.entries(purposeMap)
  189. .map(([name, sumSqm]) => ({
  190. name,
  191. value: toWanSquareMeters(sumSqm),
  192. color: getColorByName(name),
  193. }))
  194. .filter((item) => item.value > 0)
  195. .sort((a, b) => b.value - a.value);
  196. });
  197. const purposeListCompact = computed(() => {
  198. const all = purposeListFull.value;
  199. if (all.length <= PURPOSE_TOP_N) return all;
  200. const top = all.slice(0, PURPOSE_TOP_N);
  201. const restValue = all
  202. .slice(PURPOSE_TOP_N)
  203. .reduce((sum, item) => sum + item.value, 0);
  204. return [
  205. ...top,
  206. {
  207. name: OTHER_LABEL,
  208. value: Number(restValue.toFixed(2)),
  209. color: OTHER_COLOR,
  210. },
  211. ];
  212. });
  213. const pieChartList = computed(() => [
  214. {
  215. name: '图表1',
  216. text: '土地类型面积',
  217. data: isSmallScreen.value ? purposeListCompact.value : purposeListFull.value,
  218. legendHeadCount: isSmallScreen.value ? 0 : PURPOSE_TOP_N,
  219. },
  220. {
  221. name: '图表2',
  222. text: '房屋使用状态',
  223. data: usageStatusList.value,
  224. },
  225. {
  226. name: '图表3',
  227. text: '房龄分布',
  228. data: ageDistributionList.value,
  229. legendGridNoPaddingRight: true,
  230. },
  231. ]);
  232. const topicCardList = ref([
  233. { name: '专题1', text: '专题一·资产结构与分布', introduction: '用途、龄段、集团维度结构洞察', route: '/topic1' },
  234. { name: '专题2', text: '专题二·资产权属与合规', introduction: '办证进度、分线台账与集团对比' ,route: '/topic2'},
  235. { name: '专题3', text: '专题三·低效闲置资产盘活', introduction: '闲置率、闲置时长与盘活状态' ,route: '/topic3'},
  236. ]);
  237. onMounted(() => {
  238. mq.addEventListener('change', onBreakpointChange);
  239. });
  240. onUnmounted(() => {
  241. mq.removeEventListener('change', onBreakpointChange);
  242. });
  243. </script>
  244. <style scoped>
  245. .middle-wrapper {
  246. width: 100%;
  247. max-width: var(--content-max-width);
  248. display: grid;
  249. grid-template-columns: minmax(0, 708fr) minmax(0, 1150fr);
  250. gap: 14px;
  251. align-items: start;
  252. }
  253. .building-chart {
  254. width: 100%;
  255. min-width: 0;
  256. min-height: 479px;
  257. border-radius: var(--radius-card);
  258. display: flex;
  259. flex-direction: column;
  260. justify-content: space-between;
  261. gap: 12px;
  262. padding: var(--card-padding);
  263. background: var(--color-surface);
  264. border: 1px solid var(--color-accent-border);
  265. }
  266. .building-bar-chart {
  267. width: 100%;
  268. flex: 1;
  269. min-height: 320px;
  270. display: flex;
  271. }
  272. .middle-group {
  273. width: 100%;
  274. min-width: 0;
  275. display: grid;
  276. grid-template-rows: auto auto;
  277. gap: 14px;
  278. align-content: start;
  279. }
  280. .middle-group-top {
  281. width: 100%;
  282. display: grid;
  283. grid-template-columns: repeat(3, minmax(0, 1fr));
  284. gap: 16px;
  285. }
  286. .middle-group-bottom {
  287. width: 100%;
  288. display: grid;
  289. grid-template-columns: repeat(3, minmax(0, 1fr));
  290. gap: 16px;
  291. }
  292. .chart-card {
  293. width: 100%;
  294. min-width: 0;
  295. border-radius: var(--radius-card);
  296. padding: var(--card-padding);
  297. display: flex;
  298. flex-direction: column;
  299. gap: 12px;
  300. background: var(--color-surface);
  301. border: 1px solid var(--color-accent-border);
  302. }
  303. .chart-card :deep(.pie-chart-main--index) {
  304. width: 100%;
  305. max-width: 100%;
  306. height: auto;
  307. padding: 0 4px;
  308. margin-top: 4px;
  309. box-sizing: border-box;
  310. }
  311. .chart-card :deep(.donut-chart) {
  312. width: clamp(90px, 32%, 118px);
  313. height: clamp(90px, 32%, 118px);
  314. margin-bottom: 16px;
  315. }
  316. .chart-card :deep(.legend-group--grid) {
  317. width: 100%;
  318. max-width: 100%;
  319. height: auto;
  320. column-gap: clamp(10px, 3.5%, 58px);
  321. padding: 0 4px;
  322. box-sizing: border-box;
  323. }
  324. .chart-card :deep(.legend-group--grid-no-pr) {
  325. column-gap: clamp(6px, 2.5%, 34px);
  326. padding: 0 4px;
  327. }
  328. .chart-card :deep(.legend-group--grid .legend-item),
  329. .chart-card :deep(.legend-group--grid-no-pr .legend-item) {
  330. width: 100%;
  331. max-width: none;
  332. min-width: 0;
  333. }
  334. .middle-group-bottom :deep(.topic-card) {
  335. width: 100%;
  336. min-width: 0;
  337. height: auto;
  338. min-height: 141px;
  339. }
  340. .middle-group-bottom :deep(.topic-card-title),
  341. .middle-group-bottom :deep(.topic-card-Introduction),
  342. .middle-group-bottom :deep(.topic-card-push) {
  343. width: 100%;
  344. max-width: none;
  345. }
  346. @media screen and (max-width: 1440px) {
  347. .middle-wrapper {
  348. grid-template-columns: 1fr;
  349. }
  350. .middle-group {
  351. display: contents;
  352. }
  353. .middle-group-top {
  354. order: 1;
  355. }
  356. .building-chart {
  357. order: 2;
  358. min-height: 479px;
  359. }
  360. .middle-group-bottom {
  361. order: 3;
  362. }
  363. .chart-card {
  364. min-height: 280px;
  365. }
  366. }
  367. </style>