数字化大屏适配:从画布缩放到图表响应
本文是一篇实现教程,假设项目已经选择了固定画布或固定舞台 + 内部弹性布局。如果还没有确定页面策略,先阅读选型篇:固定画布、响应式与混合布局。
这里集中解决实现阶段的四个问题:Vue 如何测量真实容器并缩放 1920×1080 舞台,背景和业务画布如何分层,布局档位如何控制信息密度,以及 ECharts 如何响应自身容器。配套代码位于 example/digital-screen-adaptation。
一、实现前先分清三个尺寸
- 视口:浏览器或 Electron 内容区真实可用的 CSS 像素宽高,记为
Vw × Vh。 - 设计画布:设计稿坐标,本文使用
Dw × Dh = 1920 × 1080。 - 图表容器:某个 ECharts 节点在舞台内部最终获得的布局宽高。
布局计算应读取容器的 clientWidth/clientHeight 或 ResizeObserverEntry.contentRect。4K 是物理分辨率;受系统缩放和浏览器缩放影响,网页得到的 CSS 像素不一定是 3840×2160。devicePixelRatio 影响渲染清晰度,不直接作为布局倍率。
这三个尺寸对应三层职责:视口决定舞台能放多大,设计画布提供稳定坐标,图表根据自己的容器调整绘图区和信息密度。
二、用 Vue 实现固定舞台缩放
舞台使用 scale = min(Vw / 1920, Vh / 1080),保证整张设计画布完整显示。下面是一套可以直接放进 Vue 3 组件的完整结构。
<template>
<div ref="host" class="viewport">
<main class="stage" :class="`stage--${mode}`" :style="stageStyle">
<header>大屏标题</header>
<section class="content">
<aside>左侧指标</aside>
<div>中央地图与图表</div>
<aside>右侧指标</aside>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
const DESIGN_WIDTH = 1920
const DESIGN_HEIGHT = 1080
const host = ref<HTMLElement | null>(null)
const width = ref(DESIGN_WIDTH)
const height = ref(DESIGN_HEIGHT)
let observer: ResizeObserver | null = null
const scale = computed(() =>
Math.min(width.value / DESIGN_WIDTH, height.value / DESIGN_HEIGHT),
)
const mode = computed(() => {
const aspect = width.value / Math.max(height.value, 1)
if (aspect < 1.45 || scale.value < 0.68) return 'compact'
if (aspect > 2.15) return 'wide'
return 'standard'
})
const stageStyle = computed(() => ({
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
transform: `scale(${scale.value})`,
transformOrigin: 'center center',
}))
onMounted(() => {
if (!host.value) return
observer = new ResizeObserver(([entry]) => {
if (!entry) return
const { width: nextWidth, height: nextHeight } = entry.contentRect
if (nextWidth !== width.value) width.value = nextWidth
if (nextHeight !== height.value) height.value = nextHeight
})
observer.observe(host.value)
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<style scoped>
.viewport {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100dvh;
overflow: hidden;
}
.stage {
flex: none;
display: flex;
flex-direction: column;
}
.content {
display: grid;
grid-template-columns: 420px minmax(0, 1fr) 420px;
gap: 24px;
flex: 1;
min-height: 0;
}
.stage--compact .content {
grid-template-columns: 340px minmax(0, 1fr) 340px;
}
</style>
transform 只改变视觉尺寸,.stage 的布局尺寸仍是 1920×1080。不要再让根字号随视口变化,也不要对子区域做第二次 scale();否则实际尺寸会由多套倍率共同决定。
这里使用内联 transform,因此 CSS 不再声明另一套 transform: scale(var(...))。如果选择 CSS 变量写法,也应只保留一个倍率来源。
compact 和 wide 的阈值是示例值。实际项目要用真实标题、卡片和图例验收后确定;档位负责隐藏次要说明、改变列宽或减少信息量,不能只为了填满窗口而任意拉伸内容。
三、把背景层与业务画布分开
背景可以铺满真实视口,关键数据继续放在等比缩放的舞台中。只需在上一节的 .viewport 中增加一个与 .stage 同级的背景层:
<div class="viewport">
<div class="background" aria-hidden="true"></div>
<main class="stage">关键指标和图表</main>
</div>
.viewport {
position: relative; /* 为绝对定位的背景提供参照。 */
}
.background {
position: absolute;
inset: 0;
background: url('/screen-background.webp') center / cover no-repeat;
}
.stage {
position: relative; /* 让业务画布位于背景层上方。 */
}
视频背景可以使用 object-fit: cover。cover 会保持比例并裁切边缘,所以视觉焦点要放在安全区域,关键数字不能烘焙进背景图片。
固定画布内部尽量使用画布坐标。例如 grid-template-columns: 420px minmax(0, 1fr) 420px 在 1920px 舞台中含义稳定。若内部使用 23vw,它会先随真实视口变化,然后又随舞台整体缩放,形成两套尺寸机制。视口单位更适合背景层和舞台外浮层。
四、ECharts:监听容器,不只监听窗口
ECharts 在 init() 时读取容器大小;初始化前容器必须已有非零宽高。图表父级通常要有确定的高度,Flex 子项要设置 min-height: 0,图表节点再使用 flex: 1; min-height: 0。否则图表可能获得 0 高度,或把整个面板撑开。ECharts 官方尺寸指南特别提醒了初始化尺寸,并指出 CSS/JS 改变容器大小时,窗口 resize 不一定触发。
最直接的图表生命周期如下。完整版本在示例的 TrendChart.vue:
let chart: echarts.ECharts | null = null
let observer: ResizeObserver | null = null
let frame = 0
function schedule(width: number, height: number) {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
if (!el.value || width < 1 || height < 1) return
if (!chart) chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
chart.resize()
chart.setOption(buildOption(width), true)
})
}
onMounted(() => {
observer = new ResizeObserver(([entry]) => {
if (entry) schedule(entry.contentRect.width, entry.contentRect.height)
})
if (el.value) observer.observe(el.value)
})
onBeforeUnmount(() => {
observer?.disconnect()
cancelAnimationFrame(frame)
chart?.dispose()
})
上面为突出流程省略了避免重复 resize/setOption 的判断;示例源码包含判断。requestAnimationFrame 使同一帧多次尺寸通知合并为一次。不要在 ResizeObserver 回调里无条件修改被观察节点的 CSS 宽高;如果写回造成尺寸再次变化,可能形成观察循环。MDN 的 ResizeObserver 文档说明了容器观察的用途。
路由切换、标签页、弹窗或 KeepAlive 还需注意生命周期:图表隐藏时容器可能是 0×0,等显示后再 resize();DOM 被销毁时要 dispose();缓存页面重新激活时可再同步一次尺寸。异步数据只更新 setOption(),不要为每次数据变化重新 init()。
图表内部也要适配
chart.resize() 只调整绘图区域,并不会替你决定如何显示坐标轴文字。下面让窄容器减少刻度、缩小留白,并用 containLabel 给轴标签预留空间:
function buildOption(width: number): echarts.EChartsOption {
const narrow = width < 420
return {
grid: { left: narrow ? 12 : 20, right: 20, top: 20, bottom: 14, containLabel: true },
xAxis: {
type: 'category',
data: months,
axisLabel: { interval: narrow ? 1 : 0, fontSize: narrow ? 14 : 16 },
},
yAxis: { type: 'value' },
series: [{ type: 'line', data: values }],
}
}
实际项目还要逐类检查:
- 折线、柱状图:标签间隔、日期格式、轴名称、图例位置、
grid留白及 tooltip 是否越界;柱过密时考虑聚合或横向滚动,而不是把文字压到无法阅读。 - 饼图、环图:半径应受容器短边约束;优先检查外部标签与引导线碰撞。小尺寸时改为内部百分比或旁边的 DOM 图例。
- 地图和散点图:检查
geo区域、点大小和标签层级;需要缩放时区分地图交互缩放和整屏视觉缩放。 - Canvas 清晰度:整体放大到 4K 时检查文字与线条;Canvas/SVG 的选择取决于数据量和设备性能,不应靠反复重建图表解决模糊。ECharts 官方渲染器比较给出了选择依据。
五、验收清单与常见故障
| 场景 | 应检查的结果 |
|---|---|
| 1920×1080 | 对照设计稿检查位置、文字层级、图表留白 |
| 1280×720 | 全屏缩小后重要数字仍能读;tooltip 可操作 |
| 2560×1440、3840×2160 | 放大后图表、边框、背景清晰;动画不过载 |
| 2560×1080 或 3440×1440 | 主体完整、留白或辅助区域有设计意图 |
| 拖动窗口、浏览器缩放、Electron 全屏切换 | 不出现裁切、0 尺寸图表或持续重绘 |
| 面板自身变化、切换信息密度、路由缓存 | 无窗口变化时,图表也能跟随容器更新 |
排查时按层次看问题:背景裁切查 cover 和焦点;整体错位查舞台尺寸、缩放中心和外层居中;面板溢出查 Grid/Flex 的 min-width: 0、min-height: 0;图表留白或截断查初始化时机、容器尺寸和内部 grid/axisLabel;CPU 持续占用查 resize 事件互相派发、观察回调写回尺寸及重复初始化。
本文的方案适合以横向展示为主的数字化大屏。核心不是某一个缩放公式,而是让每层只承担自己的责任:视口决定可用空间,画布保持主视觉比例,布局档位控制信息密度,图表响应实际容器。
评论