From afc5e81299a7829963f83247bb4f4570d09d1b05 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Sun, 27 Sep 2026 16:54:47 +0800 Subject: [PATCH] =?UTF-8?q?rack=EF=BC=9A=E5=AD=97=E4=BD=93=E6=89=93?= =?UTF-8?q?=E5=8C=85=E8=BF=9B=E5=89=8D=E7=AB=AF=EF=BC=88Noto=20Sans=20SC?= =?UTF-8?q?=20=E5=8F=AF=E5=8F=98=20+=20Barlow=EF=BC=89=EF=BC=8C=E5=90=84?= =?UTF-8?q?=E7=AB=AF=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图表 init 前先 document.fonts.load 字形:zrender 按字体缓存文字宽度且不清空, 字体未到就量会让时间轴标签叠成一团。 Co-Authored-By: Claude Opus 5.5 --- rack/README.md | 4 ++- rack/frontend/package.json | 2 ++ rack/frontend/pnpm-lock.yaml | 30 ++++++++++--------- rack/frontend/src/chart.js | 11 +++++++ rack/frontend/src/components/BoardChart.vue | 14 +++++++-- rack/frontend/src/components/HistoryChart.vue | 14 +++++++-- rack/frontend/src/main.js | 8 +++++ rack/frontend/src/theme.css | 6 ++-- 8 files changed, 65 insertions(+), 24 deletions(-) diff --git a/rack/README.md b/rack/README.md index 472e4bc..e0f72c5 100644 --- a/rack/README.md +++ b/rack/README.md @@ -28,7 +28,9 @@ - 仪器前面板风格:每块板一块面板,五个「刀片槽」各有状态灯、电流读数和**最近两分钟电流小曲线**(平线 = 疑似卡死)。 只有要人看的状态(异常 / 故障 / 正在执行)上色,「运行」「断电」「空槽」保持安静;总览顶上一句话列出需要看的路。 -- 读数用 DIN 系等宽数字(系统自带:macOS/iOS `DIN Alternate`、Windows `Bahnschrift`),不加载网络字体(国内拉不到 Google Fonts)。 +- 字体随前端打包(Fontsource,不走 Google Fonts CDN),各端一致:界面 `Noto Sans SC Variable`(按 unicode-range 切约 100 片,浏览器只下用到的几片), + 读数 `Barlow`(DIN 风格,`tnum` 等宽数字,只打拉丁子集)。⚠️ ECharts 画在 canvas 上且 zrender 按字体缓存文字宽度、从不清空 —— + 图表 `init` 前必须先 `chartFonts()` 把字形下完(`src/chart.js`),否则时间轴标签会按回退字体宽度排、叠成一团。 - 暗色模式跟随系统(`html.dark`,Element Plus 的暗色变量也认它),图表在切换时重画。 - 曲线**不用双 Y 轴**:一路的电流 / 电压、底板的温度 / 风扇,都是上下两块共用时间轴(dataviz 规矩)。 两路温度用 dataviz 参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 `validate_palette.js`,全部通过。 diff --git a/rack/frontend/package.json b/rack/frontend/package.json index 33633d6..1cf6420 100644 --- a/rack/frontend/package.json +++ b/rack/frontend/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", + "@fontsource-variable/noto-sans-sc": "^5.3.0", + "@fontsource/barlow": "^5.3.0", "echarts": "^5.6.0", "element-plus": "^2.9.0", "vue": "^3.5.0", diff --git a/rack/frontend/pnpm-lock.yaml b/rack/frontend/pnpm-lock.yaml index e40fe56..4eedc8f 100644 --- a/rack/frontend/pnpm-lock.yaml +++ b/rack/frontend/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@element-plus/icons-vue': specifier: ^2.3.1 version: 2.3.2(vue@3.5.43) + '@fontsource-variable/noto-sans-sc': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/barlow': + specifier: ^5.3.0 + version: 5.3.0 echarts: specifier: ^5.6.0 version: 5.6.0 @@ -224,6 +230,12 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@fontsource-variable/noto-sans-sc@5.3.0': + resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==} + + '@fontsource/barlow@5.3.0': + resolution: {integrity: sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==} + '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -232,7 +244,6 @@ packages: engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-android-arm-eabi@4.63.5': resolution: {integrity: sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==} @@ -268,79 +279,66 @@ packages: resolution: {integrity: sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.63.5': resolution: {integrity: sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.63.5': resolution: {integrity: sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.63.5': resolution: {integrity: sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.63.5': resolution: {integrity: sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.63.5': resolution: {integrity: sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.63.5': resolution: {integrity: sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.63.5': resolution: {integrity: sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.63.5': resolution: {integrity: sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.63.5': resolution: {integrity: sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.63.5': resolution: {integrity: sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.63.5': resolution: {integrity: sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.63.5': resolution: {integrity: sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.63.5': resolution: {integrity: sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==} @@ -706,6 +704,10 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@fontsource-variable/noto-sans-sc@5.3.0': {} + + '@fontsource/barlow@5.3.0': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@napi-rs/lzma-linux-x64-gnu@1.5.1': diff --git a/rack/frontend/src/chart.js b/rack/frontend/src/chart.js index 5ebffca..462fb7c 100644 --- a/rack/frontend/src/chart.js +++ b/rack/frontend/src/chart.js @@ -14,6 +14,17 @@ import { cssVar, dark } from './theme' echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer]) export { echarts } +// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空: +// 字形没到就量,会一直按回退字体的宽度排版(时间轴标签叠成一团,重画也救不回来)。 +// 所以每张图 init 之前先把它 canvas 上要用的字形下完:全部 ASCII + 轴名里的汉字。 +// 图例和 tooltip 是 DOM,字体到了自己会换,不用管。 +const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') +export function chartFonts(...texts) { + if (!document.fonts) return Promise.resolve() + const family = getComputedStyle(document.body).fontFamily + return document.fonts.load(`12px ${family}`, ASCII + '°' + texts.join('')).catch(() => {}) +} + const SERIES = { light: ['#2a78d6', '#eb6834'], dark: ['#3987e5', '#d95926'] } export function palette() { diff --git a/rack/frontend/src/components/BoardChart.vue b/rack/frontend/src/components/BoardChart.vue index 22455e5..4a2816a 100644 --- a/rack/frontend/src/components/BoardChart.vue +++ b/rack/frontend/src/components/BoardChart.vue @@ -22,7 +22,7 @@ import RangePicker from './RangePicker.vue' import { api } from '../api' import { onMessage } from '../store' import { dark } from '../theme' -import { echarts, palette, band, stacked } from '../chart' +import { echarts, palette, band, stacked, chartFonts } from '../chart' const props = defineProps({ board: String }) const range = ref(3600e3) @@ -36,6 +36,7 @@ let live = false const degC = v => (v == null ? null : v / 1000) function render() { + if (!chart) return // 还在等字体 const p = palette() c.value = p const hasBand = pts.some(x => x[2] != null && x[2] !== x[3]) @@ -91,8 +92,15 @@ const off = onMessage(m => { }) const onResize = () => chart && chart.resize() -onMounted(() => { chart = echarts.init(el.value); load(); window.addEventListener('resize', onResize) }) -onBeforeUnmount(() => { off(); window.removeEventListener('resize', onResize); chart && chart.dispose() }) +let gone = false +onMounted(async () => { + window.addEventListener('resize', onResize) + await chartFonts('温度 °C', '风扇 %') + if (gone) return + chart = echarts.init(el.value) + load() +}) +onBeforeUnmount(() => { gone = true; off(); window.removeEventListener('resize', onResize); chart && chart.dispose() }) watch(() => [props.board, range.value], load) watch(dark, () => chart && render()) diff --git a/rack/frontend/src/components/HistoryChart.vue b/rack/frontend/src/components/HistoryChart.vue index 9afc23b..b8003fd 100644 --- a/rack/frontend/src/components/HistoryChart.vue +++ b/rack/frontend/src/components/HistoryChart.vue @@ -17,7 +17,7 @@ import RangePicker from './RangePicker.vue' import { api } from '../api' import { onMessage } from '../store' import { dark } from '../theme' -import { echarts, palette, band, stacked } from '../chart' +import { echarts, palette, band, stacked, chartFonts } from '../chart' const props = defineProps({ board: String, rail: Number }) const range = ref(600e3) @@ -28,6 +28,7 @@ let pts = [] // [t, mv, ua, ua_min, ua_max] let live = false function render() { + if (!chart) return // 还在等字体 const p = palette() const hasBand = pts.some(x => x[3] != null && x[3] !== x[4]) const mA = v => (v == null ? null : v / 1000) @@ -80,8 +81,15 @@ const off = onMessage(m => { }) const onResize = () => chart && chart.resize() -onMounted(() => { chart = echarts.init(el.value); load(); window.addEventListener('resize', onResize) }) -onBeforeUnmount(() => { off(); window.removeEventListener('resize', onResize); chart && chart.dispose() }) +let gone = false +onMounted(async () => { + window.addEventListener('resize', onResize) + await chartFonts('电流 mA', '电压 V') + if (gone) return + chart = echarts.init(el.value) + load() +}) +onBeforeUnmount(() => { gone = true; off(); window.removeEventListener('resize', onResize); chart && chart.dispose() }) watch(() => [props.board, props.rail, range.value], load) watch(dark, () => chart && render()) diff --git a/rack/frontend/src/main.js b/rack/frontend/src/main.js index 3ecb336..4c4cdb1 100644 --- a/rack/frontend/src/main.js +++ b/rack/frontend/src/main.js @@ -4,6 +4,14 @@ import ElementPlus from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' import 'element-plus/dist/index.css' import 'element-plus/theme-chalk/dark/css-vars.css' +// 字体随前端打包,不靠系统字体 —— Windows / Mac / 安卓 / iPhone 看到的一样。 +// 中文 Noto Sans SC(可变字重,按 unicode-range 分成约 100 片,浏览器只下用到的那几片); +// 读数 Barlow(DIN 风格,带 tnum 等宽数字),只要拉丁子集。 +import '@fontsource-variable/noto-sans-sc' +import '@fontsource/barlow/latin-400.css' +import '@fontsource/barlow/latin-500.css' +import '@fontsource/barlow/latin-600.css' +import '@fontsource/barlow/latin-700.css' import './theme.css' import './theme' diff --git a/rack/frontend/src/theme.css b/rack/frontend/src/theme.css index b38b916..7f53a59 100644 --- a/rack/frontend/src/theme.css +++ b/rack/frontend/src/theme.css @@ -32,9 +32,9 @@ --led-warn: #fab219; --led-bad: #e04343; - --readout: "DIN Alternate", "DIN 2014", Bahnschrift, "Roboto Condensed", "Arial Narrow", system-ui, sans-serif; - --ui: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", - "Noto Sans CJK SC", system-ui, sans-serif; + /* 两套字体都随前端打包(main.js 引入),系统字体只在字体文件还没下完时顶一下 */ + --readout: "Barlow", "Noto Sans SC Variable", sans-serif; + --ui: "Noto Sans SC Variable", sans-serif; --r-panel: 10px; --r-bay: 6px;