diff --git a/src/components/forge/Forge.vue b/src/components/forge/Forge.vue index 19fcde5..9b41038 100644 --- a/src/components/forge/Forge.vue +++ b/src/components/forge/Forge.vue @@ -1,3 +1,314 @@ + + - - - diff --git a/src/pages/nursing/index.vue b/src/pages/nursing/index.vue index 8677781..9e4c8f1 100644 --- a/src/pages/nursing/index.vue +++ b/src/pages/nursing/index.vue @@ -1,14 +1,16 @@ diff --git a/src/pages/operation/index.vue b/src/pages/operation/index.vue index 4e18d90..e942a5a 100644 --- a/src/pages/operation/index.vue +++ b/src/pages/operation/index.vue @@ -3,15 +3,15 @@ class="flex flex-col gap-2 h-[calc(100vh-72px-32px)] overflow-hidden" > -
+
-

住民人數

- +

現在住民/立案床數

+
-

住院人數

- +

今日住院/當月累積住院

+ 中,建議貼在最底部)===== -->
@@ -95,7 +95,7 @@
- + @@ -108,7 +108,7 @@ @@ -127,7 +127,7 @@
床位 姓名 性別
{{ r.bed }} {{ r.name }}
- + @@ -140,7 +140,7 @@ @@ -157,7 +157,6 @@
-

清單

床位 姓名 性別
{{ r.bed }} {{ r.name }}
@@ -189,7 +188,7 @@
-

其他人數

- - - - - -
-
-

0/49

-

-
-
-
-
-

其他人數

+

今日離院/當月累積離院

+
-

近三個月比較

-
+
+
-

近六個月比較

-
+
-

與去年同期比較

-
+ +
@@ -298,6 +273,89 @@ function genTrend(len, start = 50, drift = 0.6, noise = 20) { return arr; } +// 生成百分比(0~100)資料陣列,base 為平均,variation 為波動幅度 +function genRates(n, base = 82, variation = 10) { + const clamp = (v) => Math.max(0, Math.min(100, v)); + const arr = []; + for (let i = 0; i < n; i++) { + const noise = (Math.random() * 2 - 1) * variation; + arr.push(Number(clamp(base + noise).toFixed(1))); + } + return arr; +} + +// 取得最近 N 個月份標籤(含當月),格式:["4月","5月","6月","7月","8月","9月"] +function getLastNMonthLabels(n = 6) { + const now = new Date(); + const labels = []; + for (let i = n - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + labels.push(`${d.getMonth() + 1}月`); + } + return labels; +} + +// 量化工具:線性插值分位數 +function quantile(sorted, q) { + const pos = (sorted.length - 1) * q; + const base = Math.floor(pos); + const rest = pos - base; + return sorted[base + 1] !== undefined + ? sorted[base] + rest * (sorted[base + 1] - sorted[base]) + : sorted[base]; +} + +// 將多組樣本 => 箱型圖資料([min, Q1, median, Q3, max])與離群點 [[xIndex, value], ...] +function toBoxplot(groups) { + const boxData = []; + const outliers = []; + + groups.forEach((arr, idx) => { + const sorted = [...arr].sort((a, b) => a - b); + const q1 = quantile(sorted, 0.25); + const med = quantile(sorted, 0.5); + const q3 = quantile(sorted, 0.75); + const iqr = q3 - q1; + const lowerFence = q1 - 1.5 * iqr; + const upperFence = q3 + 1.5 * iqr; + + const inside = sorted.filter((v) => v >= lowerFence && v <= upperFence); + const min = inside.length ? inside[0] : q1; // 防守 + const max = inside.length ? inside[inside.length - 1] : q3; + + // 收集離群點 + sorted.forEach((v) => { + if (v < lowerFence || v > upperFence) outliers.push([idx, v]); + }); + + boxData.push([ + +min.toFixed(1), + +q1.toFixed(1), + +med.toFixed(1), + +q3.toFixed(1), + +max.toFixed(1), + ]); + }); + + return { boxData, outliers }; +} + +// 箱型圖 BoxPlot +function makeBoxGroups( + groupLabels, + pointsPerGroup = 30, + base = 82, + step = -1.5 +) { + const raw = groupLabels.map((_, i) => + genRates(pointsPerGroup, base + i * step, 12) + ); + const means = raw.map((arr) => + Number((arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(1)) + ); + return { raw, means }; +} + const commonGrid = { left: 40, right: 20, @@ -307,87 +365,241 @@ const commonGrid = { }; const commonTooltip = { trigger: "axis", axisPointer: { type: "shadow" } }; -// A:三個月趨勢 +function mkTitle(text, subtext = "", opts = {}) { + const useInline = !!subtext; + + if (useInline) { + return { + text: `{main|${text}} {sub|${subtext}}`, + left: 8, + top: 2, + textStyle: { + rich: { + main: { + color: brand.black, + fontSize: 24, + fontWeight: 600, + fontFamily: '"Noto Sans TC"', + }, + sub: { + color: brand.gray, + fontSize: 14, + fontWeight: 400, + fontFamily: '"Noto Sans TC"', + align: "left", + }, + }, + }, + subtext: "", + ...opts, + }; + } + + return { + text, + subtext: "", + textStyle: { + color: brand.black, + fontSize: 24, + fontWeight: 600, + fontFamily: '"Noto Sans TC"', + }, + ...opts, + }; +} + + +const baseGrid = { + top: 48, + right: 16, + bottom: 24, + left: 24, + containLabel: true, +}; + +// 佔床率(近六個月 → 箱型圖 + 平均點) function buildOptionA() { - const x = Array.from({ length: 8 }, (_, i) => `op${i + 1}`); - const legends = ["6月", "7月", "8月"]; - const series = legends.map((label, idx) => ({ - name: label, - type: "bar", - emphasis: { focus: "series" }, - data: genTrend(x.length, 40 + idx * 2, 0.5 + idx * 0.1, 1.5), - barWidth: 5, - })); - - return { - legend: { top: 10 }, - tooltip: commonTooltip, - grid: commonGrid, - xAxis: { type: "category", data: x }, - yAxis: { type: "value" }, - // 指定顏色 - color: [brand.green, brand.greenLight, brand.yellow], - series, - }; -} - -// B:六個月趨勢 -function buildOptionB() { - const x = Array.from({ length: 8 }, (_, i) => `op${i + 1}`); - const legends = ["3月", "4月", "5月", "6月", "7月", "8月"]; - const series = legends.map((label, idx) => ({ - name: label, - type: "bar", - emphasis: { focus: "series" }, - data: genTrend(x.length, 35 + idx * 1.5, 0.4 + idx * 0.06, 1.6), - barWidth: 5, - })); - - return { - legend: { type: "scroll", top: 10 }, - tooltip: commonTooltip, - grid: commonGrid, - xAxis: { type: "category", data: x }, - yAxis: { type: "value" }, - color: [ - brand.green, - brand.greenLight, - brand.yellow, - brand.purpleLight, - brand.purple, - brand.purpleDark, - ], - series, - }; -} - -// C:與去年同期比較 -function buildOptionC() { - const months = Array.from({ length: 12 }, (_, i) => `${i + 1}月`); - const lastYear = genTrend(12, 36, 0.8, 2); - const thisYear = lastYear.map( - (v, i) => Number((v + 5 + Math.sin(i / 2) * 3).toFixed(1)) // 加大偏移與震盪 + const labels = getLastNMonthLabels(6); // 近六個月 + // 產生六組樣本(每組 30 筆、約 80~92%),並限制在 0~100 之間 + const raw = labels.map((_, i) => + genRates(30, 88 - i * 1.5, 10).map((v) => Math.max(0, Math.min(100, v))) ); + const { boxData, outliers } = toBoxplot(raw); + const BOX = brand.purpleDark; + return { - legend: { top: 10, data: ["去年", "今年"] }, - tooltip: { trigger: "axis" }, - grid: commonGrid, - xAxis: { type: "category", data: months, boundaryGap: false }, - yAxis: { type: "value" }, - color: [brand.purple, brand.green], + tooltip: { + trigger: "item", + formatter: (p) => { + if (p.seriesType === "boxplot") { + const [min, med, max] = p.data; + return [ + `${labels[p.dataIndex]} 佔床率`, + `最高:${max}%`, + `中位數:${med}%`, + `最低:${min}%`, + // `(離群點依 1.5×IQR 計)`, + ].join("
"); + } + // if (p.seriesType === "scatter") { + // return `${labels[p.data[0]]}
離群點:${p.data[1]}%`; + // } + return ""; + }, + }, + title: mkTitle("佔床率", "(近 6 個月)"), + grid: { ...commonGrid, top: 60 }, + xAxis: { type: "category", data: labels }, + yAxis: { + type: "value", + name: "比例", + nameLocation: "middle", // 置中(預設 y 軸會旋轉 90 度) + nameGap: 45, + min: 0, + max: 100, + axisLabel: { formatter: "{value}%" }, + axisLine: { show: true }, + axisTick: { show: true }, + splitArea: { + show: true, + areaStyle: { color: [brand.white, brand.grayLighter] }, + }, // 淡淡分帶 + }, series: [ { - name: "去年", - type: "line", - smooth: true, - data: lastYear, + name: "箱型圖", + type: "boxplot", + data: boxData, + itemStyle: { color: brand.greenLight, borderColor: BOX }, + lineStyle: { color: BOX }, }, - { name: "今年", type: "line", smooth: true, data: thisYear }, ], }; } +// 住院率(近六個月 → 箱型圖 + 平均點) +function buildOptionB() { + const labels = getLastNMonthLabels(6); // 近六個月 + // 住院率通常低許多:每組 30 筆、約 6~14%,並限制在 0~40 + const raw = labels.map((_, i) => + genRates(30, 8 + i * 1.2, 6).map((v) => Math.max(0, Math.min(40, v))) + ); + + const { boxData, outliers } = toBoxplot(raw); + const BOX = brand.purpleDark; + + return { + tooltip: { + trigger: "item", + formatter: (p) => { + if (p.seriesType === "boxplot") { + const [min, med, max] = p.data; + return [ + `${labels[p.dataIndex]} 住院率`, + `最高:${max}%`, + `中位數:${med}%`, + `最低:${min}%`, + // `(離群點依 1.5×IQR 計)`, + ].join("
"); + } + // if (p.seriesType === "scatter") { + // return `${labels[p.data[0]]}
離群點:${p.data[1]}%`; + // } + return ""; + }, + }, + title: mkTitle("住院率", "(近 6 個月)"), + grid: { ...commonGrid, top: 60 }, + xAxis: { type: "category", data: labels }, + yAxis: { + type: "value", + name: "比例", + nameLocation: "middle", // 置中(預設 y 軸會旋轉 90 度) + nameGap: 45, + min: 0, + max: 40, + axisLabel: { formatter: "{value}%" }, + axisLine: { show: true }, + axisTick: { show: true }, + splitArea: { + show: true, + areaStyle: { color: [brand.white, brand.grayLighter] }, + }, + }, + series: [ + { + name: "箱型圖", + type: "boxplot", + data: boxData, + itemStyle: { color: brand.greenLight, borderColor: BOX }, + lineStyle: { color: BOX }, + }, + ], + }; +} + +// 每日佔床率比較(30 天 × 3 條線) +function buildOptionC() { + const days = Array.from({ length: 30 }, (_, i) => `${i + 1}日`); + + const thisMonth = genRates(30, 86, 6).map((v, i) => + Number((v + Math.sin(i / 4) * 2).toFixed(1)) + ); + const lastMonth = genRates(30, 83, 7).map((v, i) => + Number((v + Math.cos(i / 5) * 2).toFixed(1)) + ); + const lastYearSameMonth = genRates(30, 80, 8).map((v, i) => + Number((v + Math.sin(i / 3) * 1.5).toFixed(1)) + ); + + // 想拉開更多距離:legend 往下放、grid 往下放 + const LEGEND_TOP = 28; // 原本 24 → 與 title 拉開 + const GRID_TOP = 64; + + return { + title: { + ...mkTitle("每日佔床率比較", "(近 30 天)"), + top: 6, + }, + legend: { + top: LEGEND_TOP, + data: ["機構 A", "本機構", "機構 B"], + }, + tooltip: { trigger: "axis", valueFormatter: (v) => `${v}%` }, + + grid: { + ...commonGrid, + top: GRID_TOP, + containLabel: true, + }, + + xAxis: { type: "category", data: days, boundaryGap: false }, + yAxis: { + type: "value", + name: "比例", + nameLocation: "middle", + nameGap: 45, + min: 0, + max: 100, + axisLabel: { formatter: "{value}%" }, + axisLine: { show: true }, + axisTick: { show: true }, + splitArea: { + show: true, + areaStyle: { color: [brand.white, brand.grayLighter] }, + }, + }, + color: [brand.green, brand.purple, brand.yellow], + series: [ + { name: "機構 A", type: "line", data: thisMonth }, + { name: "本機構", type: "line", data: lastMonth }, + { name: "機構 B", type: "line", data: lastYearSameMonth }, + ], + }; +} + + // 初始化 & resize function initCharts() { if (chartARef.value && !chartA) { diff --git a/src/styles/palette.js b/src/styles/palette.js index 0453ce3..fab2173 100644 --- a/src/styles/palette.js +++ b/src/styles/palette.js @@ -13,5 +13,7 @@ export const brand = { black: "#424242", gray: "#828282", grayLight: "#E9E9E9", + grayLighter: "#F6F6F6", grayDark: "#D2D2D2", + white: "#ffffff" }; diff --git a/tailwind.config.js b/tailwind.config.js index e8de1c5..07d7a54 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -15,7 +15,7 @@ module.exports = { "2xl": "2.5rem", }, screens: { - // 讓 container 在各斷點對齊你自訂的寬度 + // 讓 container 在各斷點對齊自訂的寬度 sm: "640px", md: "768px", lg: "1024px", @@ -33,14 +33,14 @@ module.exports = { }, extend: { fontFamily: { - // 讓你能寫 class="font-noto" + // class="font-noto" noto: ["Noto Sans TC", "sans-serif"], - // 讓你能寫 class="font-nats" + // class="font-nats" nats: ["NATS-Regular", "sans-serif"], }, colors: { brand: { - green: { DEFAULT: "#34D5C8", light: "#C4FBE5", dark:"#0CA99C" }, + green: { DEFAULT: "#34D5C8", light: "#C4FBE5", dark: "#0CA99C" }, red: "#FF8678", purple: { DEFAULT: "#A5BEFF", @@ -54,6 +54,7 @@ module.exports = { black: "#424242", gray: { DEFAULT: "#828282", + lighter: "#EEEEEE", light: "#E9E9E9", dark: "#D2D2D2", },