File size: 20,159 Bytes
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4cb7bec
8a28d42
4cb7bec
 
8a28d42
 
4cb7bec
8a28d42
 
 
4cb7bec
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4cb7bec
 
8a28d42
4cb7bec
 
 
 
 
8a28d42
4cb7bec
 
8a28d42
4cb7bec
8a28d42
4cb7bec
8a28d42
4cb7bec
 
 
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
// AlphaEarth embeddings (tge-labs/aef on source.coop): a cell's window of its yearly COG tiles, shown as a PCA of the
// 64 bands (fast: a coarse PCA first, then the high-res image from a few bands), or one band.
//
//   footprint row (tile_row, tile_rows: lists, one entry per tile under the cell's 10,680 m window, its own first)
//     -> footprints/AEF-tiles.parquet, each tile's rows (page index) -> the year's COGs
//   each tile gives the part of the window it covers; the parts are joined (a zone's tiles share one 81,920 m grid,
//   a whole number of pixels at every level, so they fit exactly); 24.6% of cells need more than one tile
//   PCA, in two steps (each source.coop request waits ~0.7 s and few run at once, so requests set the time):
//     1. preview: each tile's whole 160 m level, all 64 bands in one request (~10 MB; the level's tiles are stored
//        together) -> the window (67 px) -> PCA (three leading components) -> shown at once (onPreview)
//        the level is kept: other cells of the same tile and year skip this read
//     2. high-res: the 8 bands that best predict the three components (forward selection, least squares on the
//        160 m pixels; R² 0.95-0.98 against the full 64-band PCA, measured at 40 m for three places) -> those bands at
//        10 m (geotiff.js, only the tiles under the window) -> the fitted mapping -> 1,068 px
//   geotiff.js reads in 1 MB blocks: the COG's headers (one per level) in one request, a band's adjacent tiles in one
//   one band: that band at 10 m, grey, 2-98% stretch; downloads: the joined window's bands at 10 m (download.js)
//   The COGs are stored south-up (row 0 at the tile's southern edge): the origin and the row direction come from
//   each file's own transform, and rows are flipped so north is up.
//   Pixels no tile covers (a zone edge, the coast) and no-data (-128) are transparent. PCA colours are the cell's
//   own: the same colour means similar embeddings within a cell, not across cells.

import { fromCustomClient, getDecoder } from 'https://cdn.jsdelivr.net/npm/geotiff@2.1.3/+esm'
import proj4 from 'https://cdn.jsdelivr.net/npm/proj4@2.12.1/+esm'
import { compressors, newStats, openFile, parquetRead } from './parquet.js'
import './zstd.js'

const HALF = 5340, NODATA = -128, FIT = 50_000, OVERVIEW = 4, HIGH = 0, CHOSEN = 8    // levels: 4 = 160 m, 0 = 10 m

/** The views offered: the PCA (default), then single bands. */
export function aefViews() {
  return [{ id: 'pca', label: 'PCA proxy of 64 bands, 10 m' },            // the 160 m PCA, predicted from 8 bands
    ...Array.from({ length: 64 }, (_, b) => ({ id: `band:${b}`, label: `Band A${String(b).padStart(2, '0')}, 10 m` }))]
}

/** A geotiff.js client that counts what it reads into `client.stats`: the read under way, set again by openCog. */
function countingClient(url, stats) {
  const client = {
    stats,
    async request({ headers, signal } = {}) {
      const r = await fetch(url, { headers, signal }), data = await r.arrayBuffer()
      client.stats.bytes += data.byteLength; client.stats.requests += 1
      return { ok: r.ok, status: r.status, getHeader: name => r.headers.get(name), getData: async () => data }
    },
  }
  return client
}

/** One tile's rows (one per year): {year, epsg, west, north, path}. */
async function tileRows(info, start, count, stats) {
  const url = new URL(`footprints/${info.tiles}`, location.href).href.split('?')[0] + location.search
  const { file, metadata } = await openFile(url, stats)
  let rows = []
  await parquetRead({ file, metadata, compressors, useOffsetIndex: true, rowStart: start, rowEnd: start + count,
    rowFormat: 'object', onComplete(r) { rows = r } })
  return rows
}

const listOf = v => (Array.isArray(v) || ArrayBuffer.isView(v) ? [...v].map(Number) : [Number(v)])

/** The cell in its UTM zone, and its tiles' files in the sample's year: {epsg, cx, cy, corners, urls}. */
export async function aefCell(info, sample, stats = newStats()) {
  const starts = listOf(sample.tile_row), counts = listOf(sample.tile_rows)
  const rows = await Promise.all(starts.map((s, k) => tileRows(info, s, counts[k], stats)))
  const tiles = rows.map(r => r.find(x => Number(x.year) === sample.year)).filter(Boolean)
  if (!tiles.length) throw new Error(`no AlphaEarth tile for ${sample.year}`)
  const epsg = Number(tiles[0].epsg)
  const utm = proj4(`+proj=utm +zone=${epsg % 100}${epsg > 32700 ? ' +south' : ''} +datum=WGS84 +units=m +no_defs`)
  const [cx, cy] = utm.forward([sample.centre_lon, sample.centre_lat])
  const corners = [[cx - HALF, cy + HALF], [cx + HALF, cy + HALF], [cx + HALF, cy - HALF], [cx - HALF, cy - HALF]]
    .map(q => utm.inverse(q))
  return { epsg, cx, cy, corners, urls: tiles.map(x => `${info.files}${x.path}`) }
}

const opened = new Map()                       // COG url -> {tiff, client, left, top, dy}, most recently used last
const KEEP = 4                                 // COGs kept open; older ones are dropped with their block cache

/** A COG, opened once (its headers in one 1 MB block), with its origin and row direction; its reads are counted
 *  into `stats` from now on. At most KEEP stay open, each caching at most 16 blocks (16 MB). */
async function openCog(url, stats) {
  if (opened.has(url)) { const c = opened.get(url); opened.delete(url); opened.set(url, c) }
  else {
    opened.set(url, (async () => {
      const client = countingClient(url, stats)
      const tiff = await fromCustomClient(client, { blockSize: 2 ** 20, cacheSize: 16 })
      const [, , , left, , dy, , top] = (await tiff.getImage(0)).fileDirectory.ModelTransformation   // dy > 0: south-up
      return { url, tiff, client, left, top, dy }
    })().catch(e => { opened.delete(url); throw e }))
    while (opened.size > KEEP) opened.delete(opened.keys().next().value)
  }
  const cog = await opened.get(url)
  cog.client.stats = stats
  return cog
}

/** Every tile's part of the window, joined: one Int8Array per band (NODATA where no tile covers). */
function join(parts, size) {
  return parts[0].map((first, b) => {
    const out = Int8Array.from(first)
    for (const part of parts.slice(1)) {
      const band = part[b]
      for (let i = 0; i < out.length; i++) if (out[i] === NODATA) out[i] = band[i]
    }
    return out
  })
}

/** Bands of the cell's window at one level, read from every tile and joined: {bands, size, west, north, res}. */
export async function aefWindow(info, sample, level, samples, stats = newStats()) {
  const cell = await aefCell(info, sample, stats), cogs = await Promise.all(cell.urls.map(u => openCog(u, stats)))
  const k = 2 ** level, size = Math.round(2 * HALF / (10 * k)), res = 10 * k
  const parts = await Promise.all(cogs.map(async c => {
    const image = await c.tiff.getImage(level), w = windowAt(c.left, c.top, c.dy, level, cell.cx, cell.cy)
    return readWindow(image, w, samples, stats, c.dy > 0)
  }))
  const w = windowAt(cogs[0].left, cogs[0].top, cogs[0].dy, level, cell.cx, cell.cy)
  return { bands: join(parts.filter(Boolean), size), size, res, west: w.west, north: w.north, cell }
}

/** One view of the cell's window in the sample's year: {src, size, values (one band: for the cursor), corners, stats}.
 *  For the PCA, onPreview(view) gets the 160 m image as soon as it is drawn. */
export async function renderAef(info, sample, viewId, stats = newStats(), onPreview = () => {}) {
  const cell = await aefCell(info, sample, stats)
  const done = async (rgba, size, values = null) => {
    const src = await png(rgba, size)
    stats.read = performance.now() - stats.t0
    return { src, size, values, corners: cell.corners, stats }
  }

  if (viewId.startsWith('band:')) {
    const window = await aefWindow(info, sample, 0, [Number(viewId.split(':')[1])], stats), { bands: [band], size } = window
    const values = { width: size, values: Float32Array.from(band, v => (v === NODATA ? NaN : v)), unit: '' }
    return { ...(await done(greyRgba(band, size), size, values)), window }            // window: for the band's GeoTIFF
  }

  stats.phase = 'Overview, 160 m'
  const cogs = await Promise.all(cell.urls.map(u => openCog(u, stats)))
  const size8 = Math.round(2 * HALF / (10 * 2 ** OVERVIEW))
  const coarse = join(await Promise.all(cogs.map(async c => {
    const overview = await overviewOf(c, stats), w = windowAt(c.left, c.top, c.dy, OVERVIEW, cell.cx, cell.cy)
    return overview.bands.map(band => cut(band, overview.width, overview.height, w, c.dy > 0))
  })), size8)
  const pca = fitPca(coarse, size8)
  if (!pca) return done(new Uint8ClampedArray(size8 * size8 * 4), size8)
  onPreview({ ...(await done(rgbOf(pca.scores, pca.valid, size8), size8)), label: 'PCA of 64 bands, 160 m' })

  stats.phase = `Loading high-res: ${CHOSEN} bands at 10 m`
  const { chosen, weights } = selectBands(coarse, pca, CHOSEN)
  const { bands: fine, size } = await aefWindow(info, sample, HIGH, chosen, stats)
  const n = size * size, valid = [], scores = [0, 1, 2].map(() => new Float32Array(n))
  for (let i = 0; i < n; i++) {
    if (fine[0][i] === NODATA) continue
    valid.push(i)
    for (let k = 0; k < 3; k++) {
      let s = weights[chosen.length][k]                                        // intercept
      for (let j = 0; j < chosen.length; j++) s += fine[j][i] * weights[j][k]
      scores[k][i] = s
    }
  }
  return done(rgbOf(scores, valid, size), size)
}

/** The cell's window on one level: its size, its position in that level's pixels (rows counted as stored), and its
 *  west and north edges in metres. */
function windowAt(left, top, dy, level, cx, cy) {
  const k = 2 ** level, res = 10 * k, size = Math.round(2 * HALF / res)
  const x0 = Math.round((cx - HALF - left) / res)
  const y0 = Math.round(Math.min((cy + HALF - top) / (dy * k), (cy - HALF - top) / (dy * k)))
  const yEdges = [top + y0 * dy * k, top + (y0 + size) * dy * k]
  return { level, size, x0, y0, west: left + x0 * res, north: Math.max(...yEdges) }
}

const overviews = new Map()                    // COG url -> its 160 m level, all bands (six kept: ~16 MB each)

/** The preview level of a COG (160 m), every band, from one request over the level's tiles (stored together), kept. */
function overviewOf({ url, tiff }, stats) {
  if (!overviews.has(url)) {
    overviews.set(url, (async () => {
      const image = await tiff.getImage(OVERVIEW), fd = image.fileDirectory, offsets = fd.TileOffsets, counts = fd.TileByteCounts
      const start = Math.min(...offsets), end = Math.max(...offsets.map((o, i) => o + counts[i]))
      stats.expected = Math.max(stats.expected ?? 0, stats.bytes) + (end - start)
      const r = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` } })
      if (r.status !== 206) throw new Error(`AlphaEarth overview: HTTP ${r.status}`)
      const data = await r.arrayBuffer()
      stats.bytes += data.byteLength; stats.requests += 1
      const decoder = await getDecoder(fd), W = image.getWidth(), H = image.getHeight(), T = fd.TileWidth
      const across = Math.ceil(W / T), down = Math.ceil(H / fd.TileLength), per = across * down
      const bands = []
      for (let b = 0; b < image.getSamplesPerPixel(); b++) {
        const band = new Int8Array(W * H)
        for (let r = 0; r < down; r++) {
          for (let q = 0; q < across; q++) {
            const t = b * per + r * across + q
            const raw = new Int8Array(await decoder.decode(fd, data.slice(offsets[t] - start, offsets[t] - start + counts[t])))
            for (let y = 0; y < T && r * T + y < H; y++) {
              const w = Math.min(T, W - q * T)
              band.set(raw.subarray(y * T, y * T + w), (r * T + y) * W + q * T)
            }
          }
        }
        bands.push(band)
      }
      if (overviews.size > 6) overviews.delete(overviews.keys().next().value)
      return { width: W, height: H, bands }
    })().catch(e => { overviews.delete(url); throw e }))
  }
  return overviews.get(url)
}

/** The window's pixels of some bands in one COG, read through geotiff.js (only the tiles under it), north up;
 *  null when the COG holds none of the window. */
async function readWindow(image, w, samples, stats, flip) {
  const W = image.getWidth(), H = image.getHeight()
  const box = [Math.max(w.x0, 0), Math.max(w.y0, 0), Math.min(w.x0 + w.size, W), Math.min(w.y0 + w.size, H)]
  if (box[2] <= box[0] || box[3] <= box[1]) return null                 // this tile holds none of the window
  stats.expected = Math.max(stats.expected ?? 0, stats.bytes) + expectedBytes(image, box, samples)
  const parts = await image.readRasters({ window: box, samples, interleave: false })
  return parts.map(part => place(part, box, w, flip))
}

/** Compressed bytes of the tiles under the window, for the bands read (planar COG: tiles band by band). */
function expectedBytes(image, [a, b, c, d], samples) {
  const fd = image.fileDirectory, T = fd.TileWidth, across = Math.ceil(image.getWidth() / T)
  const per = across * Math.ceil(image.getHeight() / fd.TileLength)
  let sum = 0
  for (const s of samples) {
    for (let r = Math.floor(b / T); r <= Math.floor((d - 1) / T); r++) {
      for (let q = Math.floor(a / T); q <= Math.floor((c - 1) / T); q++) sum += fd.TileByteCounts[s * per + r * across + q]
    }
  }
  return sum
}

/** A read box (part of the window inside the image) placed into the full window, north up, NODATA elsewhere. */
function place(part, [a, b, c, d], w, flip) {
  const out = new Int8Array(w.size * w.size).fill(NODATA), width = c - a
  for (let y = b; y < d; y++) {
    const row = flip ? w.size - 1 - (y - w.y0) : y - w.y0
    out.set(part.subarray((y - b) * width, (y - b + 1) * width), row * w.size + (a - w.x0))
  }
  return out
}

/** The window cut from a whole level held in memory. */
function cut(band, W, H, w, flip) {
  const box = [Math.max(w.x0, 0), Math.max(w.y0, 0), Math.min(w.x0 + w.size, W), Math.min(w.y0 + w.size, H)]
  const width = box[2] - box[0], part = new Int8Array(Math.max(width, 0) * Math.max(box[3] - box[1], 0))
  for (let y = box[1]; y < box[3]; y++) part.set(band.subarray(y * W + box[0], y * W + box[2]), (y - box[1]) * width)
  return place(part, box, w, flip)
}

/** PCA of the window's bands: {mean, components (3 x 64), scores (3 arrays), valid (pixel indices), fit (indices)}. */
function fitPca(bands, size) {
  const n = size * size, B = bands.length, valid = []
  for (let i = 0; i < n; i++) if (bands[0][i] !== NODATA) valid.push(i)
  if (valid.length < 2) return null
  const step = Math.max(1, Math.floor(valid.length / FIT)), fit = valid.filter((_, k) => k % step === 0)
  const mean = new Float64Array(B), cov = new Float64Array(B * B), x = new Float64Array(B)
  for (const i of fit) for (let b = 0; b < B; b++) mean[b] += bands[b][i] / fit.length
  for (const i of fit) {
    for (let b = 0; b < B; b++) x[b] = bands[b][i] - mean[b]
    for (let b = 0; b < B; b++) for (let c = b; c < B; c++) cov[b * B + c] += x[b] * x[c]
  }
  for (let b = 0; b < B; b++) for (let c = 0; c < b; c++) cov[b * B + c] = cov[c * B + b]
  const components = leading(cov, B, 3), scores = components.map(() => new Float32Array(n))
  for (const i of valid) {
    for (let k = 0; k < 3; k++) {
      let s = 0
      for (let b = 0; b < B; b++) s += (bands[b][i] - mean[b]) * components[k][b]
      scores[k][i] = s
    }
  }
  return { mean, components, scores, valid, fit }
}

/** The `count` bands that best predict the three components (forward selection on R², least squares with an
 *  intercept, from the normal equations), and the weights: rows per chosen band, then the intercept; 3 columns. */
function selectBands(bands, pca, count) {
  const B = bands.length, I = B, pixels = pca.fit, Y = pca.scores
  const G = new Float64Array((B + 1) * (B + 1)), XY = Array.from({ length: B + 1 }, () => new Float64Array(3))
  const value = (b, i) => (b === I ? 1 : bands[b][i])
  for (const i of pixels) {
    for (let b = 0; b <= B; b++) {
      const vb = value(b, i)
      for (let c = b; c <= B; c++) G[b * (B + 1) + c] += vb * value(c, i)
      for (let k = 0; k < 3; k++) XY[b][k] += vb * Y[k][i]
    }
  }
  for (let b = 0; b <= B; b++) for (let c = 0; c < b; c++) G[b * (B + 1) + c] = G[c * (B + 1) + b]
  const explained = set => {                         // sum over components of the fitted values' sum of squares
    const w = solve(set.map(b => set.map(c => G[b * (B + 1) + c])), set.map(b => [...XY[b]]))
    if (!w) return [-Infinity, null]
    let s = 0
    for (let j = 0; j < set.length; j++) for (let k = 0; k < 3; k++) s += w[j][k] * XY[set[j]][k]
    return [s, w]
  }
  const chosen = []
  for (let step = 0; step < count; step++) {
    let best = -1, bestScore = -Infinity
    for (let b = 0; b < B; b++) {
      if (chosen.includes(b)) continue
      const [s] = explained([...chosen, b, I])
      if (s > bestScore) { bestScore = s; best = b }
    }
    chosen.push(best)
  }
  return { chosen, weights: explained([...chosen, I])[1] }
}

/** Solves A W = R (A: m x m, R: m x 3) by Gaussian elimination with partial pivoting; null if singular. */
function solve(A, R) {
  const m = A.length, a = A.map((row, i) => [...row, ...R[i]])
  for (let c = 0; c < m; c++) {
    let p = c
    for (let r = c + 1; r < m; r++) if (Math.abs(a[r][c]) > Math.abs(a[p][c])) p = r
    if (Math.abs(a[p][c]) < 1e-9) return null
    ;[a[c], a[p]] = [a[p], a[c]]
    for (let r = 0; r < m; r++) {
      if (r === c) continue
      const f = a[r][c] / a[c][c]
      for (let k = c; k < m + 3; k++) a[r][k] -= f * a[c][k]
    }
  }
  return a.map((row, i) => row.slice(m).map(v => v / a[i][i]))
}

/** 2nd and 98th percentile of a sample of values. */
function range(values) {
  const v = Float64Array.from(values).sort()
  if (!v.length) return [0, 1]
  const lo = v[Math.floor(v.length * 0.02)], hi = v[Math.floor(v.length * 0.98)]
  return hi > lo ? [lo, hi] : [lo, lo + 1]
}

/** Three component images as RGB, each stretched 2-98% over a sample of the valid pixels; the rest transparent. */
function rgbOf(scores, valid, size) {
  const out = new Uint8ClampedArray(size * size * 4), step = Math.max(1, Math.floor(valid.length / FIT))
  const ranges = scores.map(s => range(valid.filter((_, k) => k % step === 0).map(i => s[i])))
  for (const i of valid) {
    for (let k = 0; k < 3; k++) out[i * 4 + k] = 255 * Math.min(Math.max((scores[k][i] - ranges[k][0]) / (ranges[k][1] - ranges[k][0]), 0), 1)
    out[i * 4 + 3] = 255
  }
  return out
}

function greyRgba(band, size) {
  const out = new Uint8ClampedArray(size * size * 4), picked = []
  for (let i = 0; i < band.length; i += Math.max(1, Math.floor(band.length / FIT))) if (band[i] !== NODATA) picked.push(band[i])
  const [lo, hi] = range(picked)
  for (let i = 0; i < band.length; i++) {
    if (band[i] === NODATA) continue
    const g = 255 * Math.min(Math.max((band[i] - lo) / (hi - lo), 0), 1)
    out[i * 4] = g; out[i * 4 + 1] = g; out[i * 4 + 2] = g; out[i * 4 + 3] = 255
  }
  return out
}

async function png(rgba, size) {
  const canvas = new OffscreenCanvas(size, size)
  canvas.getContext('2d').putImageData(new ImageData(rgba, size, size), 0, 0)
  return URL.createObjectURL(await canvas.convertToBlob({ type: 'image/png' }))
}

/** The `count` leading eigenvectors of a symmetric B x B matrix: power iteration, deflating after each. Signs are
 *  fixed (largest loading positive) so the same data gives the same colours. */
function leading(cov, B, count) {
  const m = Float64Array.from(cov), out = []
  for (let k = 0; k < count; k++) {
    let v = Float64Array.from({ length: B }, (_, b) => 1 + b / B), lambda = 0
    for (let it = 0; it < 200; it++) {
      const w = new Float64Array(B)
      for (let b = 0; b < B; b++) { let s = 0; for (let c = 0; c < B; c++) s += m[b * B + c] * v[c]; w[b] = s }
      const norm = Math.hypot(...w)
      if (!norm) break
      lambda = norm; v = w.map(e => e / norm)
    }
    let big = 0
    for (let b = 1; b < B; b++) if (Math.abs(v[b]) > Math.abs(v[big])) big = b
    if (v[big] < 0) v = v.map(e => -e)
    for (let b = 0; b < B; b++) for (let c = 0; c < B; c++) m[b * B + c] -= lambda * v[b] * v[c]
    out.push(v)
  }
  return out
}