@Lenciel

打造一张自驾地图(1)

这是「兔子洞」 系列之,如何画地图的第一部分…

前几天瞎逛,看到个北欧的公司 Fjelltopp,它的海报作品很有设计感:

fjelltopp_poster_1

图 1. 赫马万和塔纳比山谷

fjelltopp_poster_2

图 2. Galdhøpiggen 的等高线

当我看到它网站上的口号时人家的口号是:「Find your favorite mountain and bring the serenity of the mountains home with you.」 ,就想:「木里周围海拔差也很大,说不定,我也可以画出一些挺好看的地图…」。

事后发现,这个兔子洞还真是够深的。

目录

Toolchain

一些 all-in-one 的 GIS 软件,比如 QGIS,显然是能拿来做地图的。但是如果对里面大量的要素(包括比例、中心位置、不同元素的颜色、湖泊和边界有没有阴影等等)都希望自己可控,使用一个陌生而复杂的软件就很容易让人感觉无从下手。

因此我决定用 D3 :虽然少了很多脚手架,但是好处是可以在 SVG 或者 Canvas 上一点点把地图画出来,整个过程完全是通过代码就可以控制的。

至于具体是 SVG 还是 Canvas,在这个场景里面偏个人喜好。比如我就很喜欢 Canvas 里可以用类似于context.shadowBlurcontext.shadowColor 这样的声明直接控制阴影宽度和大小,但我很不喜欢 Canvas 里做各种切割和覆盖的时候要自己去控制 saverestore 的顺序。

反正最后选了 SVG。

准备画布

海报一般按照长、宽和 DPI 来确定画布的大小和清晰度。对应到屏幕上的画布,需要一个从折算。

比如,我希望以 300 的 DPI 来打印这张海报,海报的大小是 60cm x 70cm,留的边框是 3cm,那么应该是这么去声明:


const W = 708.7 //60cm
const H = 826.8 //70cm
const CM = 11.8 //300dpi下的1cm

const MARGIN = 3 * CM
const WIDTH = W - 2 * MARGIN
const HEIGHT = H - 2 * MARGIN

然后用这些数值来画出画布和画框。这里用 clipPath 去声明画框是因为后面绘制的超出画框范围内的东西可以更方便地切掉:


function drawFrame(clipId) {

    const g = svg.append("g").attr("id", MAIN_MAP_ID + '-frame')
    g.append("clipPath")
        .attr("id", clipId)
        .append("rect")
        .attr("x", MARGIN)
        .attr("y", MARGIN)
        .attr("width", W - 2 * MARGIN)
        .attr("height", H - 2 * MARGIN)
    g.append("rect")
        .attr("x", MARGIN)
        .attr("y", MARGIN)
        .attr("width", W - 2 * MARGIN)
        .attr("height", H - 2 * MARGIN)
        .attr('fill', COLOR_INNER_BACKGROUND);
}

得到的是下面的结果:

图 3. 70cm x 60cm 画布

绘制主要行政区域

这次自驾主要是在「泸沽湖-木里」这条线上,在丽江、凉山、攀枝花三个行政区域内,所以首先要把这片区域画出来。

D3 的 geo 模块是专门处理相关功能的模块,使用之前需要搞清楚三个东西:

  • Data
  • Projection
  • Path Generator

Data

D3 原生支持的数据格式是 GeoJSON。如果下载的是 TopoJSON可以理解为加入了拓扑信息的 GeoJSON 的扩展。 ,D3 提供了一些帮助函数

另外,无论 GeoJSON 还是 TopoJSON,对它们的切割和合并最好不要直接编辑文件,尽量使用 GDAL这是一个 OSGEO 基金会的开源项目,提供了很多操作数据的函数。 这样的包,里面已经处理了各种 edge case。

这里,我先在 OSM 上下载了三个区域的 GeoJSON 数据。

每个 GeoJSON 里面的原子数据集是一个个的 feature,每个 feature 主要是两部分数据:

  • geometry:多边形和点构成;
  • properties:各种附加信息,比如名称、ID、数据;

D3 已经处理了大部分 GeoJSON 的细节,所以只需要对它有基础了解就可以使用数据了。

最新版的 D3 支持了 Promise,所以加载数据可以用下面的方式:


var promises = [];

DATA_FILES.forEach(function (url) {
    promises.push(d3.json(url))
});

Promise.all(promises).then(function (values) {
    //do something with the data values
});

另外,虽然你可以单独加载和绘制每个区域,用 GDAL 里面提供的 ogrmerge.py 合并这几个城市的数据会更好:


> ogrmerge.py *.geojson -o merged_distinct.geojson -f GeoJSON -single`

Projection

要在一个平面上绘图,就需要一个从纬度/经度坐标到 (x,y) 坐标的转换,这就是一个 projection 函数。

问题是,人类已经发明了很多办法来干这个事情,光是 D3 支持的就有三大类总共十来种:

不存在完美的投影函数,因为在形状、面积、距离和/或方向这些要素里,每种算法都是对某个或某些属性精确度的选择和权衡的结果。在实际的选择中,除开去对应自己的需要,还得注意两个地方。

首先是适配数据本身的投影格式。比如打开我们下载的 GeoJSON 文件,可以看到它里面有一个WSG84的声明,这其实就对应了 geoMercator 的投影。如果你想要使用其他投影方式来展示这份数据,需要先通过工具改变它的投影(后面会说到)。

然后就是这个投影函数的一些可调参数,一般用来指定数据投影的范围等等。

有两种做法。一种是 D3 提供的 fitExtent 或者 clipExtent 函数,它们可以直接让你把数据用选中的投影函数填充或者是以某个路径切割成对应的形状:


var geoProjectionLS = d3.geoMercator()
          .fitExtent([[3 * MARGIN, 3 * MARGIN], [WIDTH, HEIGHT]], land);
//or
var geoProjectionLS = d3.geoMercator()
          .clipExtent([[MARGIN, MARGIN], [WIDTH, HEIGHT]], land);

另一种是直接指定 scale/center/translate/rotate 这些参数来更细颗粒度地控制:


const SCALE = 6587 // for print just increase this
const CENTER = [101.02, 26.53] // [lon,lat]

var geoProjectionLS = d3.geoMercator()
    .scale(SCALE * 1.5)
    .center(CENTER)
    .translate([WIDTH * 0.5, HEIGHT * 0.5])

Path Generator

这个函数负责将 GeoJSON 转换为 SVG 下的 path 。通常的做法是使用 d3.geoPath() 并指定一个 projection 来获得:

  var geoGenerator = d3.geoPath(projection);

最终渲染到 SVG 的时候我写了一个 warpper,封装掉细节,只暴露样式:

function drawMap(
  geoData,
  {
    width,
    height,
    marginTop = 1,
    marginLeft = 1,
    marginBottom = 1,
    marginRight = 1,
    padding = 30,
    projection = d3.geoIdentity().reflectY(true),
    fill = "none",
    stroke = "black",
    strokeWidth = 0.75,
    strokeLinejoin = "round",
    clipId = "none",
    mapRectId = "none",
    shadowId = "none",
    debug = false,
  } = {}
) {
  var geoGenerator = d3.geoPath(projection);
  var mapRect = d3.select("svg").append("g").attr("id", mapRectId);

  const canvas = mapRect.append("g").attr("class", "features");

  if (debug) {
    canvas
      .append("rect")
      .attr("fill", "none")
      .attr("stroke", "#f0f")
      .attr("x", marginLeft)
      .attr("y", marginTop)
      .attr("width", width - (marginLeft + marginRight))
      .attr("height", height - (marginTop + marginBottom));
    canvas
      .append("rect")
      .attr("fill", "none")
      .attr("stroke", "#f0f")
      .attr("x", marginLeft + padding)
      .attr("y", marginTop + padding)
      .attr("width", width - (marginLeft + marginRight + 2 * padding))
      .attr("height", height - (marginTop + marginBottom + 2 * padding));
  }

  canvas
    .append("path")
    .datum(geoData)
    .attr("clip-path", "url(#" + clipId + ")")
    .attr("filter", "url(#" + shadowId + ")")
    .attr("fill", fill)
    .attr("stroke", stroke)
    .attr("stroke-width", strokeWidth)
    .attr("stroke-linejoin", strokeLinejoin)
    .attr("d", geoGenerator);

  return Object.assign(mapRect, {
    props: {
      projection,
      width,
      height,
      marginTop,
      marginLeft,
      marginBottom,
      marginRight,
      padding,
      geoData,
      clipId,
      mapRectId,
    },
  });
}

到这一步,地图出来了:

how_to_map_2

图 4. 绘制主要区域

绘制水域和道路

OSM 或者国内的一些公开数据集里有到区县一级的道路和水系水域,下载之后可以直接使用。颜色上,蓝色表示水系水域,白色表示道路:

how_to_map_3

图 5. 绘制水域和道路

添加阴影

Fjelltopp 的地图之所以好看,一个很重要的原因是对阴影的使用。

在 SVG 上面添加阴影有很多办法。我选择先声明一个带 id 的阴影定义:

<svg>
    <defs>
        <filter id='shadow' color-interpolation-filters="sRGB">
            <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.5" />
        </filter>
    </defs>
</svg>

这样我在调用 wrapper 的时候只需要通过是否指定,指定哪个 id 的阴影,就可以控制阴影的样式:

// lake
drawPath(geoData[1], {
    projection: geoProjectionLS,
    stroke: COLOR_WATER,
    strokeWidth: 0.5,
    clipId: OUTTER_CLIP_FRAME_ID,
    shadowId: SHADOW_FILTER_ID, //湖有阴影
    mapRectId: MAIN_MAP_ID,
})

// river
drawPath(geoData[2], {
    projection: geoProjectionLS,
    stroke: COLOR_WATER,
    strokeWidth: 0.5,
    clipId: OUTTER_CLIP_FRAME_ID,//河没有阴影
    mapRectId: MAIN_MAP_ID,
})

how_to_map_4

图 6. 添加阴影

绘制画中画

前面的地图应该给人一种下面很空的感觉。这其实是通过调整投影中心点专门空出来了一块,用来绘制画中画。

因为实际上主要的活动轨迹虽然横跨三个区域,但核心就在丽江宁蒗,凉山木里和盐源这几个地方。所以下面的位置用来放这个区域放大的画中画,以及一些图例。

整个画中画区域从形成边界到绘制道路和水系,跟主体类似。需要注意的是一些坐标的转换,因为这里投射的中心点,以及画框,都不是从常规意义上 (0,0) 坐标点开始的:


const MULI_FRAME_WIDTH = 20 * CM;
const MULI_FRAME_HEIGHT = 22 * CM;
const MULI_FRAME_MARGIN = 2 * CM;

const DEGREE_STEP_MULI = [0.5, 0.5]
const GRATICULE_INNER_PRECISION = 0.4

const CENTER_MULI = [101.03, 29.85] // [lon,lat]

const INNER_CLIP_FRAME_ID = 'id-inner-clip-frame'
const MULI_MAP_ID = 'id-muli-map'

var geoProjectionMuLi = d3.geoMercator()
    .scale(SCALE * 1.8)
    .center(CENTER_MULI)
    .translate([WIDTH * 0.22, HEIGHT * 0.24])
//for better control we use projection above instead of fitExtent
// .fitExtent([[3 * MARGIN, 3 * MARGIN], [WIDTH, HEIGHT]], land)

这样我们就可以得到一个带画中画的地图了:

how_to_map_5

图 7. 添加画中画

绘制经纬线和坐标轴

画框里面没有地图的部分,虽然会被最后加上的图例等等零星填充,但还是有大片空白,显得有点无聊,加上经线和纬线作为标记会精神很多。

这里核心的思路是通过指定起始点的坐标和间距,在画布上进行经纬线的绘制(为了美观画了一粗一细两根):

function drawBiGraticules(
  mapRect,
  {
    step = [1, 1],
    graticulePrecision = 2.5,
    stroke = "#999",
    minorStrokeWidth = 0.15,
    minorOpacity = 0.4,
    majorStrokeWidth = 0.25,
    majorOpacity = 0.6,
    debug = false,
  } = {}
) {
  const {
    projection,
    marginTop,
    marginRight,
    marginLeft,
    marginBottom,
    height,
    width,
    padding,
    mapRectId,
    clipId,
  } = mapRect.props;

  var graticuleRect = d3
    .select("svg")
    .append("g")
    .attr("id", mapRectId + "-graticule-rect");

  var geoGenerator = d3.geoPath().projection(projection);

  // Thinner lines at half the step intervals
  var graticulesMinor = d3
    .geoGraticule()
    .step([step[0] / 2, step[1] / 2])
    .precision(graticulePrecision)();

  graticuleRect
    .append("g")
    .attr("class", "graticules")
    .append("path")
    .attr("class", "minor-graticules")
    .attr("class", "graticule")
    .attr("clip-path", "url(#" + clipId + ")")
    .attr("stroke", stroke)
    .attr("fill", "none")
    .attr("stroke-width", minorStrokeWidth)
    .attr("opacity", minorOpacity)
    .attr("d", geoGenerator(graticulesMinor));

  // Thicker lines at step intervals
  var graticulesMajor = d3
    .geoGraticule()
    .step(step)
    .precision(graticulePrecision)();

  graticuleRect
    .append("g")
    .attr("class", "graticules")
    .append("path")
    .attr("class", "major-graticules")
    .attr("clip-path", "url(#" + clipId + ")")
    .attr("stroke", stroke)
    .attr("fill", "none")
    .attr("stroke-width", majorStrokeWidth)
    .attr("opacity", majorOpacity)
    .attr("d", geoGenerator(graticulesMajor));
}

并且,通过坐标点反算出经纬度,作为轴上显示的取值:

function viewportAsGeo({
  projection,
  width,
  height,
  marginTop = 0,
  marginRight = 0,
  marginLeft = 0,
  marginBottom = 0,
  precision = 2.5,
} = {}) {
  const p1 = [marginLeft, marginTop];
  const p2 = [width - marginRight, marginTop];
  const p3 = [width - marginRight, height - marginBottom];
  const p4 = [marginLeft, height - marginBottom];

  const vertices = [p1, p2, p3, p4, p1];
  const viewportSize = Math.min(
    width - marginRight - marginLeft,
    height - marginTop - marginBottom
  );
  const parts = Math.floor(viewportSize / precision);

  let lines = pairUp(vertices);

  lines = lines
    .flatMap(([p1, p2]) => partitionLine(...p1, ...p2, parts))
    .map((l) => l[0]);

  lines = [...lines, p1];
  return {
    type: "Polygon",
    coordinates: [lines.map((p) => projection.invert(p))],
  };
}

当然,这个值是数值形式的,直接用来显示不够美观,还需要格式化成我们常见的「时分秒+NSEW」的样式:


...

d.type === "longitude" ? formatLongitude(d[0]) : formatLatitude(d[1])
...

function formatLongitude(x) {
  return x < 0 ? formatDigtalToDMS(x) + "W" : formatDigtalToDMS(x) + "E";
}

function formatLatitude(x) {
  return x < 0 ? formatDigtalToDMS(x) + "S" : formatDigtalToDMS(x) + "N";
}

function formatDigtalToDMS(x) {
  deg = 0 | (x < 0 ? (x = -x) : x);
  min = 0 | (((x += 1e-9) % 1) * 60);
  sec = (0 | (((x * 60) % 1) * 6000)) / 100;
  return deg + "°" + min + "'" + sec + '"';
}

到这里就得到了一张下面的地图:

how_to_map_6

图 8. 增加经纬度和坐标轴

和 Fjelltopp 的海报比,这张地图上缺少的东西包括:

  • 等高线;
  • 区域的地名;
  • 比例尺等图例;

我还想加的东西包括:

  • 实际的自驾轨迹;
  • 关键景点的标记;

限于篇幅,放到下篇来记录。

木里

1

小霞是射洪人,父母粮食关没过去,吃了几年百家饭。65年,她十七了,听说木里林场招工,就报了名。

小霞是个过于常见的名字,她姓什么,没人知道。不是因为她没有姓,而是她没有说过。

出发的时候是夏天。一进山谷,冷气就涌进车里,汗被收干,脊背发凉。望向窗外,只见江水把山直直劈开,路就嵌在壁上,狭窄多弯,时不时有滚石落下。好几次开着开着,车轮悬到了路外,小霞就不敢再看,闭上眼让车抖着。

感觉过了很久,总算到了驻地。欢迎宴上林场的刘书记坐到了她身旁,柔声介绍道:「这里是云贵高原与青藏高原的过渡地带,河流深切,山势险峻,人口稀少,所以保留下来成片的原始森林。」

小霞没读成书,不懂那些高原如何过渡,森林又怎么区分原始与否。她只知道,这里离她射洪的老家很远,眼睛就有些涩涩的。

书记拍了拍她的肩膀:「你这新来的姑娘,怎么垂头丧气的。我们接下来可是要替国家开发木里这座宝库啊!!」

2

林场的工作很累人。一片山的树子砍了,在山坡上分割成料子,还要再用牛马驮到城里,才能进入下一片。

林场的工作也有安逸的地方。每天慢慢地上山,慢慢地锯树,慢慢地分割。全没有过去春耕秋收父母催促着的那份紧张。

这么慢,部分是因为山确实陡,树确实粗,部分是因为干得快了,头会晕,喘不上气。

小霞的工作尤其安逸:她总被刘书记分配去收收边角,采采蘑菇。所以只需要跟着大部队,慢慢地看,慢慢地找,很多时候都歇着。

这时,刘书记总来跟她聊几句天,说你看这里天很蓝,云很低,陶渊明说悠然见南山,我觉得自己比他悠然。

就有人来跟小霞吹风,说书记部队退伍,原配在战场上牺牲了。他管着这么个林场和物资,虽然年纪大点儿,一看那满头黑发,就知道身体很好,肾气很足。

小霞这才明白了招工过程的顺利,脸红了一阵,又白了一阵。书记再找她聊天时,她说,「我不想每天采蘑菇了,我也想去砍树。」

书记的脸红了一阵,又白了一阵,说:「砍树的分队有好几个,你去最前面的那个探路的小分队吧。」

3

小分队很小,就八个人,也没有什么像样的装备。这天傍晚,走进个峡谷,天突然暗下来,风雪劈头盖脸,路自然变得模糊。大家没见过这样的天气,不敢继续往前,也不知该撤向何处。举目四望,最近的人家也挂在天边高处,遥不可及,于是心都提了起来。

正在这时,峡谷中叮叮当当地响起来,来了个马队。

都是好马,皮毛锦缎般闪亮,步子又紧又稳。马上坐的全是喇嘛,领头的一个,年纪并不大,一脸黑肉结结实实,披肩下胸还敞着,藏靴微翘地随在马镫上,一颠一颠居然显得有些轻松,让人觉得他来领头并不突兀。

那喇嘛眼神在这群汉人身上移来移去,就直直骑到了小霞面前,俯身下马骂道:「你们砍树子嘛,要遭报应哇。这个天还在沟沟头走,冻死你们狗日的。」却轻轻拍了拍身后的马背,那马就曲了前蹄把小霞请了上去。然后他转头跟其他人吆喝了几句,就把其他人也弄上了马。

没走两步,天彻底黑了,温度降得很快。转过一个垭口,是个牦牛场,放牛人搭的棚子在风雪里摇曳,仿佛在招手呼唤。一行人赶紧俯身下马,涌了进去。

棚里极暗,小霞看不清里面的布局和陈设。那喇嘛却已经生好了火,从兜里拿出个皮质的酒囊,喝一口,用袖擦一下嘴,递给小霞。

「你们喇嘛可以喝酒?」

「过去戒律多。你们又是剿又是反,各种规矩都变了,就没有管那么多了。现在如果不让喝酒吃肉,谁还当喇嘛?天太冷,大家都要喝一点,你也喝一点。」

小霞才发觉满棚都是喉头响动的声音。

这晚怎么睡着的,小霞不记得了,只记得醒来时喇嘛的披肩搭在身上,脸也蒸得热热的。

天放晴了,马队又要上路,她把披肩递给他:「谢谢,我叫小霞。」

他摆摆手笑道:「我叫多吉,是大寺拉擦的助手。外面还冷,你以后还我。」

小霞没有送他出棚,但耳朵一直支着,听那叮叮当当声渐远。然后做梦般呆了很久,好像不信这声音竟没有了。

4

很快他们又见面了。

是小霞托车站边上藏餐馆的老板约的多吉,说要请他去那里吃饭。

坐进屋里,多吉看着桌上摆得满满的,咧嘴笑了:「你还吃得来藏餐啊?」

小霞说:「请你吃饭,就要吃你吃得来的东西,这是礼貌。」

多吉端起面前的奶渣汤递给小霞:「你可以试试这个,好东西,但你们汉人可能觉得有点酸。」

小霞若无其事地做个鬼脸:「才不得,我爸妈以前就说,我是个野女子,不然我咋个敢约你吃饭」,然后就接过碗来,一仰头喝了。

多吉没骗她,这奶渣汤果然酸的,但小霞又觉得,它是甜的。

大概是林场对小霞来说太寂寞了,两人的感情升温很快。

寂寞的人能听到全世界的声音:广播里越来越激昂的口号声,锯子来来回回让树木发出的呜呜声,牛马们商量晚上吃点儿什么的私语声,连身边人明处的挤眉弄眼或暗处的追逐拥抱,也发出了声。

听见所有声音的她知道自己跟它们一点关系也没有。她面无表情地应付着手里的活路,只有跟多吉在一起的时候,才觉得四周安静下来,心里温暖又踏实。

多吉呢?多吉不是没有犹豫过。他是黄教喇嘛,只有退了身份才可以结婚生子。虽然现在喇嘛不像过去高人一等,但木里叫木里,可不就因为这哲蚌寺沿袭而来的木里大寺。汉人看着大家都是喇嘛,哪知道喇嘛有那么多等级。他从小入寺,聪慧过人,这样的年纪这样的年头成为拉擦的助手,付出过什么,放弃又意味着什么,他不知道说给谁听。

但每次小霞来了,他都不知道怎么拒绝。这样眼瞳如钻,肌肤如玉的女孩儿,不在他既往的生活里,也不在他习过的经文里。一个冬天的夜晚,他们吃完晚饭,多吉的屋里覆了灯光与落雪声,小霞就赖着不走了。

很快,小霞就不怎么再回林场宿舍。在同一张床上,她态度反复,有时乖乖伏在多吉怀里,听他讲大喇嘛当年的排场,讲大寺里里外外那些故事,或如欢喜的幼兽,上下翻腾,让彼此的身体都变得温暖;有时又忽轻忽重或不顾轻重地咬他,带着接近怨的爱。

多吉知道她想的是什么,终于有一天,他告诉小霞:「我给师父说了,后面不去寺里了。我去拉料子挣点儿钱,我们搬到县城住。」

5

拉料子很累也很危险。

最险的地方在老虎嘴,起这个名字是因为这段在悬崖上凿开的路,侧看很像微微张开的虎嘴,上下仍被峭壁含着。

也有人说,起这个名字是因为它吃了很多人。

拉料子也很挣钱。

没过多久,租来的房子就已经置办得像模像样。小霞手巧,屋子收拾得亮亮堂堂,白天多吉出门,她也到藏餐馆帮工。晚上,她就坐在进门的桌边,一边打毛线衣,一边等多吉。

桌后的墙上,小霞挂了面钟。当时挺贵,但她执意要买,因为她需要时间。无论多晚,她都坐在那儿等着,多吉也知道自己什么时候打开门,都会迎来小霞的拥抱。

三个月后,杜鹃花打起骨朵儿的一个春夜,小霞的门被敲开,外面站的是餐馆老板娘。

「多吉出事了,在老虎嘴,人没找到。」

三年灾害,小霞送走过很多人了,但这次感觉却不一样。她默默把老板娘领进屋,自己瘫坐在桌边,眼睛好像在寻什么一样,在屋子里左瞅瞅,右瞧瞧,最后又茫然地望向窗外,像什么也没有找到。

老板娘没看过风风火火的小霞这样,就也在桌边安安静静地坐下陪她。不知道过了多久,才大声地说:

「睡觉吧!」

这大声不是怒,也不是急,只是带来了坏消息于是有点同仇敌忾想要打断厄运的那种大声:

「我很累了,你不累吗?我要睡觉了。有什么事情都明早再说吧。」

「这么晚你就在里面睡吧,辛苦你了。」

小霞把她领进卧室,再走回客厅,顺手把灯光给拨灭,继续坐在那儿恍惚,好像要把世界坐成末日。

但其实不行,末日都是自己的,无法分给世界。

不知过了多久,墙上那面钟渐渐看得清了。

五点二十七分。

6

多吉死后,有过一些议论。

有的喇嘛说,木里的森林本来都是大喇嘛的「菩萨林」,老百姓是不能进去乱搞的。多吉以前最讨厌把山砍得光秃秃的汉人,结果自己去拉料子挣钱,于是遭了报应。

还有更难听的。

小霞正式被林场开除了,却也不怎么去藏餐馆。

她常梦见多吉,两人坐在满天繁星下,都知道相聚甚短,奈何额头相抵,四目紧闭,把彼此的手握得发潮发痛,却仍抵抗不了天一寸寸亮起来。

所以,她见不得藏餐。

老板和老板娘看她日渐萧索,就托人给她讨了个车站里打扫卫生的活。他们不忙的时候,就走过来陪她说几句话。后来车站过夜车多了,需要一个人守门,小霞就搬进了门卫室。

门卫室很小,一半是她的床,一半是她收来的废品。

入夏以后,繁弦急管地播报多了起来,街上也贴满了大字报。车站的领导给小霞说,最近不用上班了。老板和老板娘也把店关了,来聊天时,眉头紧锁。武斗很快开始,一派占了县中学,一派占了县委党校。枪炮声时时传来,街上的喇嘛却一天天多了起来。

他们说,大寺被毁了,几楼高的甲娃强巴佛,身上的珠宝被抠得精光。他们说,木里大寺真大啊,火烧了四天四夜,还没有烧光。他们说,更多的喇嘛没能下山,当官的汉人也是疯的疯,病的病,死的死。

小霞不知道眼前这些事该怪谁,只是偶尔在心里恨恨地想:「你们又是干了什么,遭了今天的报应呢?」

就这么乱了几年,不知道哪天开始,突然就又安静下来。有一天,老板和老板娘来找小霞,说餐馆重新开业了,一起吃个饭。

进了屋,桌上摆得满满的。小霞端起奶渣汤,才喝了两口,就忽然咧开嘴恸哭起来。

她哭得那样用力,还伴着阵阵嘶吼,好像有什么大仇,永远无法得报。

老板娘的眼睛里也都是泪。

老板说:「都别哭了,吃吧。」

小霞就仰头继续喝汤,任眼泪不停灌进嘴里。

她觉得,奶渣汤是酸的,眼泪是咸的。