安卓方块字散架
现象 逐字符回退,格宽不一。
修法 自托管 TigerMonoArt 子集,并验证 cmap。
00 / INDEX
tiger@opentiger:/lab$ ls -R ./chapters
01 / GRAMMAR
EVERY PIXEL IS EARNED, NOT FADED IN
这套页面只守一条总则:元素不是被轻轻放进视口,而是被搭建、输入或解密出来。点阵凝聚、乱码解码、打字机、擦入生长与节制的 glitch,是允许进入画面的五种方式。
FADE IN + MOVE Y.
FRAME BUILT.
BUILD → HOLD → CORRODE
02 / FIELD
INK DENSITY BEFORE DECORATION
画面不用透明渐变区分层级,而用“墨量”排序:文字遮罩最深,其次是密字符、疏字符,最浅才是半调点。十档双变体字符坡道按格位固定选取,所以场会变化,字形却不会无意义闪动。
指针把能量写进附近格子,点击则放出速度为 430px/s、寿命为 2s 的 ASCII 涟漪。能量按真实时间衰减;滚动掉帧时波前追赶,而不是暂停。
const RAMPS = [[".", ","], [":", ";"], ["-", "~"], ["=", "+"], ["*", "x"], ["%", "&"], ["#", "0"], ["@", "$"], ["▓", "#"], ["█", "█"]];
const R_CHAR = 140, DECAY = .93, E_FLOOR = .14;主页源码把字符坡道与三项能量参数关在工厂闭包里;本页仅把三项常量改成可写参数并暴露 setter,演示与生产引擎保持同一条渲染路径。
PARAMETER CONSOLE / MOVE + CLICK THE FIELD
> 拖动滑杆 · 在场里移动/点击
03 / LUMA
THE FRAME IS DATA, NOT FOOTAGE
主页没有播放 mp4 画面。隐藏视频只充当解码器:每个视频帧先按场的宽高比 cover 居中裁切,再降采样到约 7k 个像素,逐格明度决定点径与字符档位。素材只需 320px 灰度,60s ≈ 400KB。
lo / hi 裁黑白场,gamma 压住中灰杂讯,时间平滑抑制压缩抖动。视频未加载、自动播放被拦或半路出错时,场会沿 vFade 回到纯径向底场;hero 离屏还会暂停解码。
function vSample() {
if (!vTone || vid.readyState < 2) return;
const va = vid.videoWidth / vid.videoHeight, fa = W / H;
let sw = vid.videoWidth, sh = vid.videoHeight;
if (va > fa) sw = sh * fa; else sh = sw / fa;
scx.drawImage(vid, (vid.videoWidth - sw) / 2, (vid.videoHeight - sh) / 2, sw, sh, 0, 0, cols, rows);
const d = scx.getImageData(0, 0, cols, rows).data;
const { lo, hi, gamma } = videoCfg;
for (let idx = 0, p = 0; idx < cols * rows; idx++, p += 4) {
const L = (d[p] * .299 + d[p + 1] * .587 + d[p + 2] * .114) / 255;
let t = (L - lo) / (hi - lo); t = t < 0 ? 0 : t > 1 ? 1 : t;
const tone = Math.pow(1 - t, gamma);
vTone[idx] += (tone - vTone[idx]) * .6;
}
vReady = true;
}裁切发生在采样前:无论视频比例怎样变化,格子读到的始终是铺满场的中心画面。
ffmpeg -ss 起点秒 -t 时长秒 -i 原片.mp4 -an -vf "fps=24,scale=320:-2,hue=s=0,curves=all='0/0 0.25/0.10 0.75/0.92 1/1'" -c:v libx264 -crf 32 -preset veryslow -pix_fmt yuv420p -movflags +faststart media/新片名.mp4换片只有两步:先把对比度烘进 320px 灰度素材,再改引擎调用的 src;部署时媒体文件单独同步。
04 / SCRUB
GEOMETRY IS THE ONLY CLOCK
“进视口播一次”会在惯性滚动与回看时重播,也会让页底元素被最大滚动距离钳坏。这里直接读取元素几何位置:出现看下界,消失看上界,构建和腐蚀是两个独立分支。
短标题用三倍自身高度拉长解码行程;页底元素再按可达距离收紧,于是滚动条到底正好完成尾标。乱码刷新频率从静止时约 1.5 次/秒 随滚速提升到约 30 次/秒,完成或离屏后停止闪烁。
u.setProg = () => {
const r = triggerEl.getBoundingClientRect(), vh = innerHeight;
if (r.top < 0) { u.out = true; u.prog = Math.min(1, Math.max(0, 1 + r.top / (r.height * .85))); }
else {
/* 文字元素矮 → 出现结束点抬高:底边升至距视口底 2×自身高度处播完(行程 3h);
按可达行程收紧,页底文字(尾标)到底时恰好播完 */
const minTop = r.top + scrollY - (document.documentElement.scrollHeight - vh);
const D = Math.max(1, Math.min(3 * r.height, vh * .9, vh - Math.max(0, minTop)));
u.out = false; u.prog = Math.min(1, Math.max(0, (vh - r.top) / D));
}
};映射只依赖 getBoundingClientRect(),不用 ScrollTrigger 的 progress 判断相位。
GEOMETRY SCRUB / DRAG THE RANGE
05 / TYPE
ONE GRID, ONE VERIFIED GLYPH SET
ASCII 画看似只是一段文本,实际依赖每个格子同宽、每个制表与方块字符都有字形。安卓本地字体缺这批字形,Menlo Bold 也缺制表符;第三方子集甚至可能声明了 unicode-range,却没有真实 cmap。
所以站点自托管仅 8.9KB 的 TigerMonoArt 子集,只把普通终端文字留给系统等宽栈。中文栈最前还放了一张只接管全角标点的 CJKPunct 字体面,避免 Geist 把全角标点渲染成窄标点。
现象 逐字符回退,格宽不一。
修法 自托管 TigerMonoArt 子集,并验证 cmap。
现象 Chrome 不跨字重回退,线框错位。
修法 所有 ASCII 画固定 font-weight: 400。
现象 字符数据正确,视觉宽度却像半角。
修法 CJKPunct 用 unicode-range 抢回标点渲染。
pyftsubset DejaVuSansMono.ttf --unicodes="U+0020-007E,U+00A0,U+00B7,U+2013-2015,U+2500-25FF" --flavor=woff2 --layout-features='' --no-hinting --desubroutinize裁剪集同时保留 Latin、制表、方块与几何字符。含制表符的画始终使用 --mono-art 且字重 400。
06 / PERF
REMOVE THE SLOW PATH FIRST
Safari 的短板不是算法本身,而是数千次 canvas 调用与若干 WebKit 慢路径。优化把 DPR 压到 1,把 CELL 分母从 9500 → 7000、点数变化 −26%,同时收紧绘制批次与合成层边界。
所有半调点并入一条 Path,最后只执行一次 fill;字符按偶数字号分组,减少 ctx.font 切换。
Safari 半帧率渲染、DPR 压到 1,并把点距分母从 9500 调到 7000,点数变化 −26%。
噪点 SVG filter 关闭、mix-blend-mode 退回 normal、滚动内容移除 mask;避免大面积 fixed 滤镜层。
SVG scrub 把连续路径和离散标注分层;必须离散更新的文字按 1/24 量化并跳过相同值。
build() 遇到同尺寸直接跳过,移动端地址栏伸缩不再清空能量;真正重建也保留正在播放的涟漪。
ctx.beginPath(); /* 所有点并入同一路径,最后一次 fill(Safari 关键优化) */> 结论:新效果一律先过 html.safari 降载检查 ▮
07 / CHARTS
GRIDS, BLOCKS, MONO LABELS
日报没有引入通用图表库。格子、方块字符与等宽标注本来就是 console 语言的一部分,自绘让数据图和页面共用同一套视觉语法,也省去依赖,并让轴线、格子与点位能按“构建”逻辑进入画面。
格子条负责排名与量级,方块散点负责两维权衡与最优象限,方点折线负责时间趋势。三种图只共用一个 chart-head:左边是终端标题,右边是 FIG.xx ▮,其余结构跟着数据形态变化。
注:冲击与成本均为作者自评,科学性约等于在终端里画一只老虎。
网格、刻度与图例保持静态,不进入逐帧更新。
轴线与路径独立合成,只承接连续 transform。
点位与文字按离散步进出现,避免反复栅格化。
.svg-stack { position: relative; }
.svg-stack .svg-anim, .svg-stack .svg-over { position: absolute; inset: 0; height: 100%; pointer-events: none; }
.svg-stack .svg-anim { will-change: transform; transform: translateZ(0); }源码把中层固定为独立合成层;构建进度绑定滚动几何,向上回看时沿同一时间轴逆转,不另起一次播放。
08 / MEDIA
IMAGE, FALLBACK, VIEWER, TAPE
一张信号卡的头图不是单一路径。数据先尝试 images[] 多图卡带,再尝试 image 单图;没有图片或加载失败,才由程序化纹理生成 fallback。无论从哪条路径到达,卡片尺寸与信息层级都不塌。
真实图片也要进入站内影调:灰度、轻微对比增强,再覆一层点阵。查看大图时,墨色马赛克先拼成背景,图片随后阶梯式显形;媒体因此不是“贴上去”,而是按同一套构建语言进入页面。
PROGRAMMATIC FALLBACK / SELECT A MODE


MOSAIC VIEWER / OPEN + CLOSE

MOSAIC: inset −2px + tile outline 1px;查看器支持滚轮/双指缩放与拖拽平移,并先锁定标签与按钮宽度再做文字解码。



墨量排序,能量写进格子,涟漪按真实时间衰减。
HEAT █████元素几何就是进度;构建与腐蚀沿同一路径往返。
HEAT ████░格子、方块与 mono 标注,把数据也写成 console。
HEAT ████░四角独立探色,输出纯黑或纯白,拒绝合成反色。
HEAT █████左侧卡带使用 scroll-snap 与实时计数;右侧轨道复制两份后循环 transform,悬停暂停,触屏与 reduced-motion 下退为原生横向滚动。
09 / CURSOR
FOUR CORNERS, ONE VIEWFINDER
原生指针被接管后,四个角标组成一套取景器语言:空闲时合成小框与中点,链接上放大并旋转 45°,按钮、图片和滑杆上则飞出框住目标。锁定中心保留极轻的视差,但滑杆拖动直接追随把手。
颜色不用 blend 反相。四个角标各自用 elementsFromPoint() 找脚下第一个不透明背景并计算明度;图片则从 32×32 缓存画布读取真实像素。因此取景器跨过深浅交界时,可以同时出现黑角与白角。
四角收成小框,中点紧跟实际指针。
链接态放大并旋转 45°,不夺走文字形态。
四角飞向目标外沿,中心吸附目标或滑杆把手。
CURSOR TARGET RANGE / MOVE ACROSS THE PANEL
触屏、减少动态或动画库不可用时,靶场隐藏;系统归还原生指针,并保留所有目标的正常点击/拖动能力。
const imgLum = (img, x, y) => {
try {
let cx = imgCache.get(img.currentSrc);
if (!cx) {
const cv = document.createElement("canvas");
cv.width = 32; cv.height = 32;
cx = cv.getContext("2d", { willReadFrequently: true });
cx.drawImage(img, 0, 0, 32, 32);
imgCache.set(img.currentSrc, cx);
}
const r = img.getBoundingClientRect();
const px = Math.min(31, Math.max(0, ((x - r.left) / r.width * 32) | 0));
const py = Math.min(31, Math.max(0, ((y - r.top) / r.height * 32) | 0));
const d = cx.getImageData(px, py, 1, 1).data;
if (d[3] < 128) return null;
return (.2126 * d[0] + .7152 * d[1] + .0722 * d[2]) / 255;
} catch (e) { return null; }
};
const probeColor = (x, y) => {
for (const el of document.elementsFromPoint(x, y)) {
if (el.tagName === "IMG" && el.complete && el.naturalWidth) {
const lum = imgLum(el, x, y);
if (lum !== null) return lum < .45 ? "#fff" : "#000";
continue;
}
const m = getComputedStyle(el).backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m || (m[4] !== undefined && parseFloat(m[4]) < .5)) continue;
return (.2126 * m[1] + .7152 * m[2] + .0722 * m[3]) / 255 < .45 ? "#fff" : "#000";
}
return "#000";
};来源是主页真实实现:图片采样失败时返回 null,探测继续沿背景色链向下找,不把跨域异常变成光标异常。
ENABLE WHEN: (pointer: fine) + min-width: 761px + prefers-reduced-motion: no-preference + html.anim。任一条件不满足,原生指针原样归还。