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 poster's default shape is a product decision, not a fix.
This commit is contained in:
parent
c56c4d1c35
commit
84e1e0cf42
4 changed files with 148 additions and 4 deletions
78
internal/check-mercator-slice.mjs
Normal file
78
internal/check-mercator-slice.mjs
Normal 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.')
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
57
library/maps/mercator-slice.js
Normal file
57
library/maps/mercator-slice.js
Normal 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
|
||||
}
|
||||
|
|
@ -233,6 +233,7 @@
|
|||
"check:settled": "node internal/settled-tree-gate.mjs",
|
||||
"check:element-bootstrap": "node internal/check-element-bootstrap.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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue