牛一越界,大屏 3 秒飞到它身边:我用真实卫星数据 + Three.js 做了个 3D 智慧牧场

牛一越界,大屏 3 秒飞到它身边:我用真实卫星数据 + Three.js 做了个 3D 智慧牧场

一个「智慧牧场 · 草畜平衡」三维大屏的完整实现:从 Copernicus 地形和 Sentinel-2 卫星影像出发,沿河谷切出一块 12×8 km 的沙盘,用逐月 NDVI 表现草场长势,程序化生成牧场、5780 头牛羊和告警仿真,最后让页面和设计稿逐区比对。

在线体验:dani-bin.github.io/screen-demo...

一、真草原,假牧场

这是我大屏作品合集里的第十个 demo,题材是智慧农牧。动手之前,先用一组选择题把需求定下来:

问题 选择
牧场类型 草原放牧(牛 + 羊)
规模 合作社 / 集团牧场
主线 草畜平衡与划区轮牧 + 畜群定位与安全
季节 夏秋放牧期(7--8 月)
交互 告警联动 + 草场长势历史
场地 真实的中国草原

场地选在呼伦贝尔陈巴尔虎旗的莫日格勒河,号称「天下第一曲水」,河在草原上拐出几十道弯。选它有两个原因:这里是典型的夏营地,而且地形和卫星影像都有免费的公开数据。

但牧场本身是虚构的。所以整个项目有一条约定:真实的部分如实说,虚构的部分不说成真的。

内容 来源 真 / 假
地形 Copernicus GLO-30 数字高程(30 m) 真实
底图 Sentinel-2 L2A,2026 年 7--8 月晴空像素中值合成(10 m) 真实
草场长势 Sentinel-2 逐月 NDVI(2025、2026 年 4--10 月) 真实
河道、湿地 地形谷底线 + 影像水体 + ESA WorldCover 真实
牧场边界、40 块轮牧分区、营地、饮水点 顺着真实河谷规划 虚构
5400 只羊、380 头牛、项圈、告警 仿真 虚构

技术栈

环节 工具
页面 Vue 3 + Vite,px→rem 等比缩放(postcss-pxtorem,设计稿 1920 宽)
三维 three.js:自定义 ShaderMaterial 地形、Canvas 贴图叠加层、Points + InstancedMesh 牛羊、gsap 镜头
数据 Python:rasterio 读云上的 COG,NumPy / SciPy / scikit-image 加工
仿真 纯 JS,按日期播种的伪随机数,时间跟北京时间走
验收 无头 Chrome 截图 + Python 逐区比对

这次没有用 Blender:地形本身就是数据,营地、饮水点和牛羊都是代码拼出来的低模。

二、整体流水线

和之前几个项目一样,坚持一条原则:页面用到的数据全部由脚本生成,不手改。

text 复制代码
fetch_data.py(下载:DEM / Sentinel-2 / WorldCover)
 └─→ models/smart-ranch/raw/   16×14 km、正北朝上,不进仓库
                │
prepare.py(唯一的数据加工入口)
 ├─→ terrain_h.png    1201×801 高程网格(16 位拆成 R/G 两个字节)
 ├─→ imagery.webp     2400×1600 夏季真彩色底图
 ├─→ ndvi_a/b.png     4--10 月逐月 NDVI(7 个月塞进 7 个通道)
 ├─→ layout.json      沙盘朝向、河道、保护带、40 块分区、设施、分区逐月 NDVI
 └─→ plan-v1.png      规划平面图(给人看)
                │
RanchScene.js(three.js,不依赖 Vue)+ simulator.js(仿真)+ Vue 面板

想换一块草原,只要改 fetch_data.py 里的中心经纬度,重跑两个脚本。

三、取数据:不注册账号,直接读云上的 COG

Copernicus DEM 和 Sentinel-2 在 AWS 上都有公开桶,文件是 COG(Cloud Optimized GeoTIFF)。COG 支持 HTTP Range 请求,只读需要的那一小块,不用把整景几百 MB 的文件下下来。

3.1 一个函数:远程读 + 重投影 + 拼接

rasterio 打开 /vsicurl/ 开头的地址就是远程读,再用 reproject 直接投到自己定义的 10 m 网格上。跨分幅时多次调用,已有的值保留,空洞由下一幅补上:

python 复制代码
def warp_into(url, dst, resampling, band=1, src_nodata=None):
    """把远程 COG 的一个波段重投影进目标数组;目标里已有的有效值保留(多幅拼接用)"""
    crs, transform, _, _ = grid()
    tmp = np.full(dst.shape, np.nan, dtype=np.float32)
    with rasterio.open("/vsicurl/" + url) as src:
        reproject(
            source=rasterio.band(src, band),
            destination=tmp,
            dst_transform=transform,
            dst_crs=crs,
            src_nodata=src_nodata if src_nodata is not None else src.nodata,
            dst_nodata=np.nan,
            resampling=resampling,
        )
    hole = np.isnan(dst)
    dst[hole] = tmp[hole]

地形按 1° 分幅,文件名形如 Copernicus_DSM_COG_10_N49_00_E120_00_DEM,30 m 用三次卷积插到 10 m,缓丘过渡更顺。

3.2 Sentinel-2:先看云,再下载

影像用 Element84 的 Earth Search(STAC 接口)检索。三个小技巧让跨境网络下也能稳定跑完:

  1. 只要用得到的字段 :STAC 响应默认带全部元数据,约 1.6 MB,经常读到一半断掉;用 fields 只取 id、时间、几个波段地址,响应降到几十 KB;
  2. 先读场景分类图 SCL:SCL 是 20 m 分辨率、体积小,先用它算窗口内的晴空占比,不到 50% 的日期直接跳过,省掉大部分下载;
  3. GDAL 超时和重试 :GDAL_HTTP_TIMEOUT、GDAL_HTTP_MAX_RETRY 设上,按年月前缀开几个进程并行下载。
python 复制代码
body = {
    "collections": ["sentinel-2-l2a"],
    "bbox": list(bbox_lonlat(pad=0)),
    "datetime": f"{start}T00:00:00Z/{end}T23:59:59Z",
    "query": {"eo:cloud_cover": {"lt": 80}},
    # 只取用得到的字段,响应从约 1.6 MB 降到几十 KB
    "fields": {"include": fields},
    "limit": 100,
}

3.3 一个差点漏掉的坑:NDVI 算出了 1.27

NDVI =(近红外 − 红光)÷(近红外 + 红光),理论上在 −1 到 1 之间。可我算出来的 4 月份,出现了 1.27 这种不可能的值。

查下来是 L2A 的反射率偏移问题:2022 年起的处理基线 04.00 给反射率加了 +1000 的偏移,Earth Search 用 earthsearch:boa_offset_applied 标记是否已经扣除。2026-04-03 这一景标记成「未扣除」,数值却和同月已扣除的景一样。照标记再扣一次 1000,暗像元变成负数,NDVI 就爆了。

处理办法是三道保险:标记不一致的景直接跳过;有效像素要求红光、近红外反射率都为正;最后再把 NDVI 截到 −1, 1。

python 复制代码
# 处理基线 04.00 起 L2A 数值带 +1000 偏移,Earth Search 绝大多数景已经扣除。
# 个别景标记为未扣除、数值却和已扣除的一致,照标记再扣会把暗像元扣成负数,
# 所以标记不一致的景直接跳过
if not all(meta.get("boaOffsetApplied") or [False]):
    skipped_offset.append(day)
    continue

def clear_mask(b):
    """晴空且反射率有效(红光、近红外都为正)的像素"""
    return np.isin(b["scl"], SCL_CLEAR) & (b["red"] > 0.002) & (b["nir"] > 0.002)

教训:数据源的元数据也会错,算完一定要看值域。

四、把数据变成沙盘

4.1 河太细,用地形找河谷

莫日格勒河只有 10--20 m 宽,在 10 m 影像上断断续续,直接找水体会把旁边的牛轭湖当成河。换个思路:河一定在谷底。把高程当成代价图,求一条从西南走到东北、「尽量走低处」的最小代价路径,就是谷底线:

python 复制代码
from skimage.graph import route_through_array

def thalweg_path(dem, start, end):
    """在 dem 上求一条"尽量走低处"的最小代价路径(行列号数组)"""
    cost = (dem - dem.min() + 1.0) ** 3  # 高出谷底越多代价越大,路径贴着谷底走
    path, _ = route_through_array(cost, start, end, fully_connected=True, geometric=True)
    return np.array(path)

立方让高处的代价涨得很快,路径就会老老实实贴着谷底走。

4.2 沿河谷旋转切沙盘

河谷是斜的(西南---东北走向),正北朝上切一个矩形,河会从对角线穿过去,两边浪费一大片。所以对谷底线的点做一次主成分分析,用主方向当沙盘的 X 轴,算出来河谷方向是 39.6°,然后沿这个方向切一块 12×8 km 的沙盘:

python 复制代码
class Sandbox:
    """沙盘坐标系:中心 (cx, cy)、河谷方向 theta(弧度,从正东逆时针)"""

    def to_geo(self, X, Y):
        return self.cx + X * self.c - Y * self.s, self.cy + X * self.s + Y * self.c

    def resample(self, arr, order=1):
        """把下载网格上的栅格采样到沙盘网格"""
        x, y = self.to_geo(self.X, self.Y)
        col = (x - GX0) / PIXEL - 0.5
        row = (GY0 - y) / PIXEL - 0.5
        return ndi.map_coordinates(arr, [row, col], order=order, mode="nearest")

之后所有东西(高程、影像、NDVI、分区、设施)都用沙盘坐标,页面里不再关心经纬度。下图是采样出来的 7--8 月真彩色底图:

4.3 逐月 NDVI:7 个月塞进 7 个通道

每个月把所有晴空日期的 NDVI 逐像素取中值,整月都被云挡住的像素沿月份方向线性插值补上:

python 复制代码
def monthly_ndvi(scenes, year):
    """某年 4--10 月逐月 NDVI:当月所有晴空日期的逐像素中值"""
    by_month = {m: [] for m in MONTHS}
    for d, b in scenes:
        if d.year == year and d.month in by_month:
            m = clear_mask(b)
            nd = np.clip((b["nir"] - b["red"]) / np.maximum(b["nir"] + b["red"], 1e-4), -1, 1)
            by_month[d.month].append(np.where(m, nd, np.nan))
    ...

4--10 月一共 7 个月,正好塞进两张 PNG:ndvi_a 的 RGBA 放 4--7 月,ndvi_b 的 RGB 放 8--10 月。每个像素值按 (NDVI + 0.2) ÷ 1.2 映射到 0--255。页面切月份时不换纹理,只改着色器里的权重(见 5.1)。

4.4 高程塞进 PNG

高程要 1 cm 精度,8 位不够。做法是减去 600 m 的基准、乘 100,变成 16 位整数,拆成高字节放 R、低字节放 G,存成无损 PNG。

页面里读的时候有个坑:浏览器解码图片默认会做颜色空间转换和 alpha 预乘,字节会被改掉,高程就全乱了。必须显式关掉:

js 复制代码
const bmp = await createImageBitmap(blob, {
  colorSpaceConversion: "none",
  premultiplyAlpha: "none"
})
// ...画到 canvas 上取像素
for (let i = 0; i < data.length; i++)
  data[i] = (px[i * 4] * 256 + px[i * 4 + 1]) / hScale + hOffset

4.5 在真河谷上排一个假牧场

牧场按 10 个轮牧单元、每个单元 4 块分区排成方格,顺着河谷两岸铺开。河道两侧留出禁牧保护带,营地、饮水点、场部和中继基站挑地势平坦的位置放。脚本按约 9 万亩的目标面积搜索单元尺寸,最后排出来暖季草场约 9.3 万亩,其中轮牧分区约 8.5 万亩,河岸保护带约 0.77 万亩:

每块分区还算好了 2025、2026 年逐月的 NDVI 均值,写进 layout.json,面板上的「长势矩阵」和「同比」直接用它。

五、three.js 场景

5.1 地形着色器:卫星图、NDVI 和牧场外降饱和

地表只有一个 ShaderMaterial,片元着色器里做三件事:

  1. 在牧场范围内,把卫星影像和当前月份的 NDVI 色带混合;
  2. 牧场外降饱和、压暗,把视线引到牧场里;
  3. 拉近时叠一层草地噪声(见 5.2)。
glsl 复制代码
void main() {
  vec3 col = texture2D(uImg, vUv).rgb;
  float inside = texture2D(uMask, vUv).r;

  // 当前月份的 NDVI(两个月之间按权重插值),像素值 0--1 → NDVI −0.2--1
  float v = dot(texture2D(uNdviA, vUv), uWA) + dot(texture2D(uNdviB, vUv).rgb, uWB);
  float ndvi = v * 1.2 - 0.2;
  col = mix(col, ramp(ndvi), uNdviMix * inside);

  // 牧场外:降饱和、压暗
  float lum = dot(col, vec3(0.299, 0.587, 0.114));
  col = mix(col, vec3(lum), uDesat * (1.0 - inside));
  col *= 1.0 - uDim * (1.0 - inside);
  // ......光照、雾
}

uWA、uWB 是 7 个月的权重,用点积一次取出当前月份的值。月份可以是小数,比如 6.5 就是 6 月、7 月各一半,月份播放时颜色是连续变化的:

js 复制代码
export function monthWeights(month) {
  const w = new Array(7).fill(0)
  const f = THREE.MathUtils.clamp(month - 4, 0, 6)
  const i = Math.floor(f)
  const t = f - i
  w[i] = 1 - t
  if (i < 6) w[i + 1] = t
  return {
    a: new THREE.Vector4(w[0], w[1], w[2], w[3]),
    b: new THREE.Vector3(w[4], w[5], w[6])
  }
}

5.2 竖向放大 + 近景草地噪声

沙盘 12 km 宽,高差只有 270 m 左右,按真实比例看几乎是平的,所以高度放大了 2.4 倍,缓丘才看得出来。

另一个问题是 10 m 的影像拉近到几百米会糊成一片。解决办法是在着色器里叠几层不同尺度的值噪声,调明暗和黄绿,只在镜头靠近时淡入:

glsl 复制代码
if (uDetail > 0.0) {
  float near = uDetail * (1.0 - smoothstep(1200.0, 4500.0, distance(vWorld, cameraPosition)));
  float n = vnoise(vWorld.xz / 3.0) * 0.5 + vnoise(vWorld.xz / 11.0) * 0.3 + vnoise(vWorld.xz / 37.0) * 0.2;
  col *= 1.0 + (n - 0.5) * 0.28 * near;
  col = mix(col, col * vec3(1.06, 1.03, 0.86), (vnoise(vWorld.xz / 90.0) - 0.4) * near * 0.8);
}

5.3 5780 头牛羊:远处是点,近处是模型

全景时镜头在 30 km 外,一只羊连一个像素都不到;拉近到几百米又要看得出是羊。所以牛羊有两套画法:

  • 远景用 Points:一只一个点,点的大小随距离夹在 1--5 倍之间,羊群看起来是一团白;
  • 近景用 InstancedMesh:羊和牛各一个程序化低模,几千只一次绘制,模型放大 2.2 倍便于辨认;
  • 相机距离在 3600--6500 m 之间时两套交叉淡化,不会「啪」地一下切换。
js 复制代码
const NEAR_IN = 3600
const NEAR_OUT = 6500

update(camDist) {
  // 仿真每秒给一份状态,这里在前后两份之间按时间插值,画面不跳
  const k = Math.min(1, (performance.now() - this.t0) / this.dt)
  const near = THREE.MathUtils.smoothstep(camDist, NEAR_IN, NEAR_OUT)
  this.pointMat.uniforms.uOpacity.value = near
  this.points.visible = near > 0.01
  this.sheepMesh.visible = this.cowMesh.visible = near < 0.99
  // ......逐只计算位置,写进实例矩阵
}

位置怎么来?真实牧场里羊是按群管理的,只有头羊戴定位项圈;牛每头都有项圈。仿真也照这个来:羊群里每只羊的位置 = 群中心 + 一个固定的随机偏移 × 散开程度(采食时约 70 m、饮水时 40 m、夜宿时 22 m);牛直接用仿真里每头牛的坐标。

营地、饮水点、场部的低模是对着几张 AI 生成的近景概念图拼的(几个盒子、圆柱和斜屋顶),这几张图同时也是告警卡、视频墙里的「示意画面」,页面上标了「示意」:

5.4 告警效果按镜头距离缩放

告警有三样东西:竖直的光柱、地面的扩散波纹、发光的围栏段。全景时要足够醒目,拉近到几百米时又不能占满整个画面,所以每帧按镜头距离算一个缩放系数:

js 复制代码
// 告警效果按镜头距离缩放:全景时醒目,拉近后不至于占满画面
const ks = THREE.MathUtils.clamp(this.camDist / 20000, 0.12, 1)
for (const o of this.fx.children) {
  if (o.name === "wave") {
    const k = (t % 2) / 2
    o.scale.setScalar((0.6 + k * 1.4) * ks)
    o.material.opacity = 0.6 * (1 - k)
  } else if (o.name === "ring") o.scale.setScalar(ks)
  else if (o.name === "beam") o.scale.set(ks, 1, ks)
}

5.5 地表叠加层:一张 Canvas 画所有线

围栏、分区编号、轮牧色块、放牧热力、测距线都画在一张 Canvas 上,当作地表的叠加纹理。同一张图远近共用会有问题:全景时合适的线宽,拉近后变得又粗又糊。所以镜头跨过 6 km 时重画一次,近景版线宽减半、不画大字。

5.6 性能

10 m 网格的地形有 1201×801 个顶点,约 192 万个三角形,M2 上只有 40 帧左右。地形改成隔一个顶点取一个(20 m 网格),约 51 万个三角形;10 m 的细节由影像和法线体现,肉眼看不出区别。现在 M2 上 1920×1080、2 倍像素密度稳定 60 帧。

六、仿真:让牧场按北京时间「活」着

大屏没有后端,所有数据来自一个纯 JS 的仿真器。它要满足三点:跟墙上的钟同步 、刷新页面不跳变 、数字讲得通。

6.1 时间:永远停在放牧季

大屏设定在 2026 年 7--8 月。把真实日期按「一年中的第几天」对 62 取余,映射到这 62 天里的某一天,时刻用真实的北京时间:

js 复制代码
export function demoTime(ms = Date.now()) {
  const p = Object.fromEntries(fmtParts.formatToParts(new Date(ms)).map((x) => [x.type, x.value]))
  const realDay = Date.UTC(+p.year, +p.month - 1, +p.day)
  const doy = Math.round((realDay - Date.UTC(+p.year, 0, 1)) / DAY_MS)
  const dayStart = SEASON_START + (doy % SEASON_DAYS) * DAY_MS
  const hours = +p.hour + +p.minute / 60 + +p.second / 3600
  // ......
}

这样一年里任何一天打开,都是草最好的季节;而羊群早上出牧、中午饮水、晚上归牧的节奏,和观众手表上的时间一致。

6.2 草畜平衡:数字讲得通

面板上最重要的一个数是「草畜平衡指数」,背后的口径是:

  • 羊单位:1 只成年绵羊 = 1,1 头牛 = 5;每羊单位每天吃 1.8 kg 干草;
  • 产草量(干重,kg/亩)= K ×(NDVI − 0.15),NDVI 用的是这块分区真实的卫星值;
  • 合理载畜量 = Σ 分区面积 × 产草量 × 利用率 50% ÷(1.8 kg × 暖季 183 天);
  • 草畜平衡指数 =(实际载畜量 − 合理载畜量)÷ 合理载畜量,≤ 10% 算不过牧。

K 不是拍脑袋,而是反过来标定的:让全场指数落在 +5% 左右,再给每个单元一个压力系数,有的偏高、有的偏低,面板上才有红有绿。

轮牧也按规则走:每个单元 4 块分区依次放,一季轮 3 遍,每块放多少天按它的产草量分配。

6.3 告警:按日期播种,刷新不跳

告警要随机,但不能每次刷新都不一样。做法是每 10 分钟一个时段,用「日期 + 时段」当种子生成伪随机数,决定这个时段出不出事、出什么事:

js 复制代码
function dayEvents(dayKey, plans) {
  const list = []
  for (let slot = 36; slot < 132; slot++) {  // 6:00--22:00
    const r = rng(`${dayKey}-ev-${slot}`)
    if (r() > 0.3) continue                   // 约三成时段出一个事件
    let pick = r() * total
    const tpl = EVENT_KINDS.find((k) => (pick -= k.weight) < 0)
    const start = (slot * SLOT_MIN + r() * 4) / 60
    const dur = (8 + r() * 17) / 60
    // ......越界选一头牛,离群选一个头羊项圈,低水位选一个饮水点
  }
}

同一时刻算出来的数据永远一样,两块屏同时打开也一致。告警会经历「待处理 → 已派单 → 处理中 → 已解决」,越界、离群、饮水点水位低、项圈低电量、项圈离线五类。还有一条规则:畜群平时绝不进河岸保护带,只有「越界」事件才把那头牛放进去。

七、交互:巡游、告警联动、长势播放

7.1 自动巡游

大屏挂在墙上没人操作时,镜头自己巡游:全景停 14 秒,然后飞到一个畜群的近景停 9 秒,再回全景,依次轮换。有人拖动、点选就停下,空闲 45 秒后恢复。

7.2 告警联动

新告警出现时(发生在 3 分钟内,且不是页面刚打开时已有的),镜头自动飞到现场停 15 秒,选中出事的畜群,画出它近 6 小时的轨迹:

js 复制代码
function linkEvent(e) {
  const a = locateEvent(e, state.value, layout)
  linked.value = e.id
  stopTour()
  sel.value = a.herd && e.kind !== "water"
    ? { type: "herd", id: a.herd }
    : { type: "water", id: e.target }
  scene.flyTo(close(a.pos, 1300, 32), 2.6)
  linkTimer = setTimeout(() => {
    linked.value = ""
    sel.value = null
    runTour()
  }, LINK_HOLD * 1000)
}

右侧的告警卡上有「查看视频 / 轨迹回放 / 电话放牧员 / 关闭告警」四个处置按钮,派单状态会跟着仿真往前走。

7.3 长势播放

底部的月份条可以点任意月份,也可以从 4 月一路播到 10 月。因为月份只是着色器里的权重,播放就是用 gsap 补间一个数:

js 复制代码
playMonths.tl = gsap.timeline({ onComplete: () => setMonth(state.value.t.month) })
playMonths.tl.to(proxy, {
  m: 10,
  duration: 8,
  ease: "none",
  onUpdate: () => ((month.value = proxy.m), scene.setMonth(proxy.m))
})

八、上线后被提的问题:普通浏览器窗口里,左栏底部被裁了

定稿要求「两侧栏铺满到底,不留空」,我在无头 Chrome 1920×1080 下验收全部通过。结果用户在自己的浏览器里一打开,左下角的「牧区气象」「称重日增重」两张卡被屏幕底边裁掉了:

量下来是三个原因叠加:

  1. 页面只按宽度缩放 。全站用 amfe-flexible 设 rem = 视口宽 ÷ 10。浏览器窗口去掉标签栏、地址栏后几乎总比 16:9 扁,宽度决定的高度放不下;
  2. 模板里写死的 px 不会被转换 。postcss-pxtorem 只处理 CSS,走势小图的 <svg width="110">、环图的内联 style 都是绝对像素,屏幕不是 1920 宽时它们相对变大;
  3. 侧栏本来就卡在极限。1920×1080 下左栏五张卡就比可用高度多 3 px,文字行高按整像素取整,不同缩放下还会多出几像素。

对应三处修改。第一处,这个页面挂载期间改成按宽、高取小:

js 复制代码
// rem = min(宽, 高 × 16/9) / 10:窗口偏扁时界面整体按高度缩、两侧栏贴边
function fitRem() {
  const el = document.documentElement
  const w = Math.min(el.clientWidth, (el.clientHeight * 16) / 9)
  el.style.fontSize = `${w / 10}px`
}

amfe-flexible 的 resize 监听先注册,本页的后注册、后执行,正好覆盖它的结果;离开页面时还原成按宽度。三维标注的锚点和镜头偏移也改用同一个比例换算,否则标签会和地图错开。

第二处,走势小图和环图的尺寸换算成 rem。第三处,给侧栏加一个兜底:内容超出可用高度时整栏等比缩小。

js 复制代码
function fitColumn(col) {
  const st = col.style
  st.transform = st.height = st.bottom = ""
  const avail = col.clientHeight
  const last = col.lastElementChild
  // 最后一张卡平时伸展填满剩余高度,量自然高度时先不让它伸展
  last.style.flexGrow = "0"
  const need = col.scrollHeight
  last.style.flexGrow = ""
  if (need <= avail + 1) return
  st.bottom = "auto"
  st.height = `${need}px`
  st.transform = `scale(${avail / need})`
}
// 窗口缩放、卡片内容变化(告警出现 / 消失)都会改变高度,统一由 ResizeObserver 触发

修完在 6 种窗口尺寸下量了一遍,两栏最后一张卡都不超出底部卡片下沿:

窗口 左栏处理
1920×1080 不缩放,和定稿逐像素一致
2000×1117 缩 0.3%
1920×960 缩 1.5%
1512×845 缩 1.7%
2560×1300 缩 0.3%
1280×800 不缩放(窗口比 16:9 高)

另外还测了浏览器最小字号设成 12px 的情况:小字被强制放大,左栏缩到约 78%,也能完整显示。

教训:侧栏排满到底的大屏,验收时一定要在几种「扁」的浏览器窗口里量一遍,不能只看 1920×1080。

九、和设计稿逐区比对

设计稿先出了 A、B、C 三个方向,选了「A 晴空牧场」;第一版被嫌「功能太少、数据单一」,加了模块;又被要求「两侧栏下方不要留空」,补满后定稿。

有一点做得比较省事:设计稿 HTML 直接调用页面同一份 RanchScene 渲染三维,面板是手写的 HTML。所以定稿里的地形、色彩、机位和页面天然一致,实现阶段主要是把面板搬进 Vue。

验收时用 ?at=2026-07-25T13:58 把仿真固定在定稿那一刻,无头 Chrome 截图,再用 Python 脚本逐区比对:

区域 指标 结果
天空 平均色差 ΔE 0.01
沙盘中部 平均色差 ΔE 1.0
沙盘前沿 平均色差 ΔE 0.01
侧壁 平均色差 ΔE 0.0
顶部指标卡 超差像素 2.09%
左栏 超差像素 2.50%
功能导航 / 工具条 / 底部卡片 / 右栏 超差像素 4.4%--11.4%

地表四块取色区的色差都在 1 以内。面板里超出 3% 门槛的几块,主要是所有文字约 1 像素的基线偏移,加上秒数、告警闪烁这些实时变化;右栏是我主动改成和底部卡片下沿对齐(定稿里它多出 10 px),肉眼看不出区别。这也说明逐像素门槛对 1 px 的偏移太敏感,比对脚本还需要加一点位移容忍。

十、踩坑汇总

现象 原因 解决
NDVI 出现 1.27 某一景的反射率偏移标记错了,被多扣了一次 跳过标记不一致的景,要求反射率为正,NDVI 截到 −1, 1
找河道找成了湖 河太细,水体指数抓到旁边的牛轭湖 用地形求谷底线定河道
牧场方格放不进沙盘 河谷是斜的,正北朝上的矩形浪费一半 主成分分析求河谷方向,沿河谷旋转切沙盘
STAC 检索经常读断 默认响应约 1.6 MB,跨境网络不稳 fields 只取需要的字段,加重试和本地缓存
OSM Overpass 连不上 国内网络 OSM 改成可选数据
高程全乱 浏览器解码图片时做了颜色转换和预乘 createImageBitmap 关掉 colorSpaceConversion、premultiplyAlpha
面板和设计稿差一截 Tailwind 基础样式把行高设成 1.5 页面根节点 line-height: normal
拉近后围栏又粗又糊 叠加层远近共用一套线宽 镜头跨过 6 km 换「近景细线版」
拉近后告警光柱占满画面 尺寸按全景定的 按镜头距离缩放
只有 40 帧 地形 192 万个三角形 隔点取 20 m 网格,约 51 万个,60 帧
浏览器窗口里左栏被裁 rem 只按宽度 + 模板 px 不转换 rem 按宽高取小、模板尺寸改 rem、侧栏兜底缩放

十一、总结

回头看,这个项目有几个做法值得记下来:

  1. 真实数据打底,虚构业务挂在上面:地形、影像、长势都是真的,牧场是假的,并且在页面底部、文章里如实标注;
  2. 脚本流水线 :页面用到的数据全部由 prepare.py 生成,换一块草原只要改一个经纬度;
  3. 大屏至少要有远、近两套画法:叠加层、牛羊、告警效果都要分远近,否则全景好看的东西拉近就露馅;
  4. 别只在 1920×1080 下验收:墙上的屏是 16:9,但第一个看到它的人,多半是在一个浏览器窗口里。

你们做大屏的时候,用过真实的遥感数据吗?欢迎在评论区聊聊。


说明:地形 Copernicus GLO-30 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018;影像与 NDVI:Contains modified Copernicus Sentinel data 2025--2026。牧场布局、牛羊和运行数据均为演示,近景概念图为 AI 生成的示意画面。

相关推荐
cc_瀚海知行1 小时前
工具调用 6|助手什么时候该停下来?
java·服务器·前端·人工智能·python·ai
溪语流沙1 小时前
【Web全栈进阶】Nginx反向代理 + HTTPS:正式上线
前端·vue.js·python·nginx·https·fastapi
vivo互联网技术1 小时前
域名埋点零侵入实时校验实践:基于代理转发与规则驱动的工具设计
前端·测试·监控·代理·埋点
喵个咪1 小时前
GoWind ShortPlay 拆解:Flutter 沉浸式 Feed 的播放器工程——生命周期、手势状态机与游客降级
前端·flutter·架构
白日做梦Q1 小时前
Chrome商店访问不了,无法现在安装插件?利用Edge商店迁移扩展完整实操教程问题背景
前端·chrome·edge
涛涛ing1 小时前
2026年,你的前端终于不用再“算随机数”和“量高度”了
前端
用户298698530141 小时前
React 前端 Word 转图片:页面与元素级导出的工程实践
前端·javascript·react.js
彭亚川Allen2 小时前
从零手搓 RAG 之分块策略
前端·后端·面试