The poster sliced its rows in degrees, and Mercator charges more for a degree up north

DRV1650-SR-POSTER-EXPORT, the "too much Antarctica" half. The big-poster renderer
stitches a grid of tiles, each drawn into an EQUAL-HEIGHT slot of the output image, and
it chose each row's latitude band by dividing the degree span evenly:

    latStep = (ne.lat - sw.lat) / rows

Screen height is proportional to ln(tan(pi/4 + lat/2)), not to latitude, so equal DEGREE
bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is
37.0% of the true rendered height and was being painted into 25% of the poster; the next
19.7% into 25%; the bottom 25.2% into 25%. Every row is squeezed or stretched, which is
the wrong aspect, and the bands where a degree buys the most pixels are the most
over-represented -- the Antarctica the Driver saw.

Sliced in Mercator y instead, every row is 25.0%, exactly, because that IS the screen
coordinate.

★ THIRD PLACE TODAY FOR THE SAME MISTAKE. A whiteboard chart nearly shipped with a linear
scale into degrees; a measurement harness nearly reported a false threshold by correcting
the wrong axis; and this has been shipping. Treating latitude as a screen coordinate is
the error, the conversion is one line, and it now lives in library/maps/mercator-slice.js
rather than being rediscovered a fourth time.

check-mercator-slice asserts the PROPERTY across five view/row combinations -- every band
an equal share of rendered height, contiguous, covering the span, north-first -- and its
CONTROL is the old code: degree-linear slicing must FAIL the same assertion, which it does
by 12.0 points. Without that control the test would pass on a tree where nothing changed.
A -90..90 view clamps at the Mercator limit instead of going infinite.

NOT FIXED HERE, because it is a product decision and not a defect: the export also asks
for an 8.5x11 PORTRAIT sheet (research-map.vue:1410), so a landscape map is letterboxed
onto a tall page. That is a deliberate default, it is the other half of "the wrong
vertical aspect", and changing the shape of the Driver's poster is his call, not mine.
This commit is contained in:
Doxa Maps 2026-09-28 17:24:47 +00:00
parent 948a00c7da
commit 859bb268e2
4 changed files with 148 additions and 4 deletions

View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* check-mercator-slice — a poster's tile rows must be equal on SCREEN, not equal in degrees.
*
* THE DEFECT (Driver, 2026-09-28): Simple Research's Export → poster "shows too much of Antarctica"
* and comes out at the wrong, vertical aspect.
*
* The big-poster renderer stitches tiles, each drawn into an EQUAL-HEIGHT slot of the output. It picked
* each row's latitude band by dividing the degree span evenly — but screen height is proportional to
* ln(tan(pi/4 + lat/2)), not to latitude. So equal degree bands are unequal screen bands, and painting
* them into equal slots squeezes some and stretches others.
*
* ★ THE ASSERTION IS THE PROPERTY, AND THE CONTROL IS THE OLD CODE. Every band must occupy the same
* share of the rendered height, AND the degree-linear slicing it replaced must FAIL that same check —
* otherwise this passes on a tree where nothing was fixed.
*
* Exit 0 pass · 1 fail.
*/
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const { equalHeightLatBands, mercatorY, latAtMercatorY, MERCATOR_MAX_LAT } =
await import(resolve(ROOT, 'library/maps/mercator-slice.js'))
const fails = []
const shares = (bands, s, n) => {
const tot = mercatorY(n) - mercatorY(s)
return bands.map(([bs, bn]) => (mercatorY(bn) - mercatorY(bs)) / tot)
}
for (const [s, n, rows] of [[-60, 75, 4], [-85, 85, 6], [-10, 10, 3], [20, 60, 5], [-60, 75, 1]]) {
const bands = equalHeightLatBands(s, n, rows)
if (bands.length !== rows) { fails.push(`${rows} rows requested, ${bands.length} returned`); continue }
const sh = shares(bands, s, n)
const worst = Math.max(...sh.map((x) => Math.abs(x - 1 / rows)))
if (worst > 1e-9) {
fails.push(`bands of a ${s}..${n} view in ${rows} rows are not equal on screen: `
+ `shares ${sh.map((x) => (x * 100).toFixed(1) + '%').join(' ')}`)
}
// north-first, contiguous, and covering the whole span
if (Math.abs(bands[0][1] - Math.max(s, n)) > 1e-9) fails.push(`first band does not start at the north edge (${s}..${n})`)
if (Math.abs(bands[rows - 1][0] - Math.min(s, n)) > 1e-9) fails.push(`last band does not end at the south edge (${s}..${n})`)
for (let i = 1; i < rows; i++) {
if (Math.abs(bands[i][1] - bands[i - 1][0]) > 1e-9) fails.push(`bands ${i - 1}/${i} are not contiguous`)
}
}
// ★ THE CONTROL: the degree-linear slicing this replaced MUST fail the same property.
const linearBands = (s, n, rows) => {
const step = (n - s) / rows
return Array.from({ length: rows }, (_, r) => [n - (r + 1) * step, n - r * step])
}
const linWorst = (() => {
const sh = shares(linearBands(-60, 75, 4), -60, 75)
return Math.max(...sh.map((x) => Math.abs(x - 0.25)))
})()
if (linWorst <= 1e-9) {
fails.push('degree-linear slicing PASSES the equal-height assertion, so the assertion cannot detect '
+ 'the defect it exists for — it is measuring nothing.')
}
// The poles are undefined in Mercator; a world view must clamp rather than produce Infinity.
const polar = equalHeightLatBands(-90, 90, 4)
if (!polar.every(([a, b]) => Number.isFinite(a) && Number.isFinite(b))) {
fails.push('a -90..90 view produces non-finite bands; Mercator is undefined at the poles and must clamp')
}
if (Math.abs(polar[0][1]) > MERCATOR_MAX_LAT + 1e-6) fails.push('the north edge was not clamped to the Mercator limit')
if (Math.abs(latAtMercatorY(mercatorY(41.2)) - 41.2) > 1e-9) fails.push('mercatorY/latAtMercatorY are not inverses')
if (fails.length) {
console.error('MERCATOR-SLICE: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log(`MERCATOR-SLICE: PASS — every tile row occupies an equal share of the RENDERED height across five `
+ `view/row combinations, degree-linear slicing fails the same check (worst band off by ${(linWorst * 100).toFixed(1)} `
+ 'points), and a polar view clamps instead of going infinite.')

View file

@ -32,6 +32,7 @@
*/
import { ref, readonly } from 'vue';
import { equalHeightLatBands } from '../../maps/mercator-slice.js';
import { computeLayout } from './usePosterLayout.js';
import { engineFor } from '../useMapProvider.js';
@ -240,16 +241,23 @@ export function useMapPoster(getSourceMap) {
const ne = sourceBounds.getNorthEast();
const sw = sourceBounds.getSouthWest();
const lngSpan = ne.lng - sw.lng;
const latSpan = ne.lat - sw.lat;
const lngStep = lngSpan / cols;
const latStep = latSpan / rows;
// ★ ROWS ARE SLICED IN MERCATOR Y, NOT IN DEGREES, because each tile is drawn into an
// EQUAL-HEIGHT slot of the output image and screen height is not linear in latitude.
// Dividing the degree span evenly (what this did) squeezes some bands and stretches others:
// MEASURED on a -60..75 view in 4 rows, the top band is 37.0% of the true screen height and was
// being painted into 25% of the poster, the next 19.7% into 25%. That is the wrong aspect, and
// the high-latitude bands — where a degree buys the most pixels — are the most over-represented,
// which is the "too much Antarctica" the Driver reported.
const latBands = equalHeightLatBands(sw.lat, ne.lat, rows);
let completed = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
_throwIfAborted(signal);
const tileSw = [sw.lng + (c * lngStep), ne.lat - ((r + 1) * latStep)];
const tileNe = [sw.lng + ((c + 1) * lngStep), ne.lat - (r * latStep)];
const [bandS, bandN] = latBands[r];
const tileSw = [sw.lng + (c * lngStep), bandS];
const tileNe = [sw.lng + ((c + 1) * lngStep), bandN];
hiddenMap.fitBounds([tileSw, tileNe], { padding: 0, animate: false, duration: 0 });
await _waitForIdle(hiddenMap);
const tileUrl = hiddenMap.getCanvas().toDataURL('image/png');

View file

@ -0,0 +1,57 @@
/**
* mercator-slice.js — cut a map's bounds into EQUAL-HEIGHT rows, which is not the same as equal degrees.
*
* ★ THE DEFECT THIS EXISTS FOR (Driver, 2026-09-28, Simple Research's poster export): the poster "shows
* too much of Antarctica" and comes out at the wrong, vertical aspect.
*
* The big-poster renderer stitches a grid of tiles, each rendered into an EQUAL-HEIGHT slot in the
* output image. It chose each tile's latitude band by dividing the degree span evenly:
*
* latStep = (ne.lat - sw.lat) / rows
*
* Web Mercator is not linear in latitude — screen y is proportional to ln(tan(pi/4 + lat/2)) — so equal
* DEGREE bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is 37.0%
* of the true screen height and is drawn into 25% of the poster; the next is 19.7% drawn into 25%. Every
* row is squeezed or stretched, which is the wrong aspect, and the high-latitude bands — where a degree
* is worth the most pixels — are the most over-represented, which is the Antarctica the Driver saw.
*
* Slicing in MERCATOR Y instead gives 25.0% per row exactly, because that IS the screen coordinate.
*
* ★ SAME MISTAKE, THIRD PLACE TODAY. A whiteboard chart nearly shipped with a linear scale into degrees,
* and a measurement harness nearly reported a false threshold by correcting the wrong axis. Anything
* that treats latitude as a screen coordinate is wrong; the conversion is one line and it belongs
* somewhere shared rather than being rediscovered.
*/
/** Web Mercator's y for a latitude, in the same units the projection's x uses (radians of longitude). */
export const mercatorY = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI / 180) / 2))
/** The inverse: the latitude at a Mercator y. */
export const latAtMercatorY = (y) => (Math.atan(Math.sinh(y)) * 180) / Math.PI
/** Web Mercator is undefined at the poles; the engines clamp here, so clamp before projecting. */
export const MERCATOR_MAX_LAT = 85.051129
const clampLat = (lat) => Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, lat))
/**
* Latitude bands of EQUAL RENDERED HEIGHT, north to south.
* @param {number} southLat
* @param {number} northLat
* @param {number} rows
* @returns {Array<[number, number]>} [southLat, northLat] per row, ordered north-first to match a
* top-to-bottom tile grid
*/
export function equalHeightLatBands(southLat, northLat, rows) {
const n = Math.max(1, Math.floor(rows) || 1)
const yN = mercatorY(clampLat(Math.max(southLat, northLat)))
const yS = mercatorY(clampLat(Math.min(southLat, northLat)))
const step = (yN - yS) / n
const out = []
for (let r = 0; r < n; r++) {
const top = yN - r * step
const bottom = yN - (r + 1) * step
out.push([latAtMercatorY(bottom), latAtMercatorY(top)])
}
return out
}

View file

@ -225,6 +225,7 @@
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
"check:settled": "node internal/settled-tree-gate.mjs",
"check:map-menu-anchor": "node internal/check-map-menu-anchor.mjs",
"check:mercator-slice": "node internal/check-mercator-slice.mjs",
"check:embed-url-absolute": "node internal/check-embed-url-absolute.mjs",
"check:setup-order": "node internal/check-setup-order.mjs",
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",