From 84e1e0cf42377ae0b59069d1d21b1f7cef2719b9 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 17:24:47 +0000 Subject: [PATCH] The poster sliced its rows in degrees, and Mercator charges more for a degree up north MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/check-mercator-slice.mjs | 78 ++++++++++++++++++++++ library/composables/poster/useMapPoster.js | 16 +++-- library/maps/mercator-slice.js | 57 ++++++++++++++++ package.json | 1 + 4 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 internal/check-mercator-slice.mjs create mode 100644 library/maps/mercator-slice.js diff --git a/internal/check-mercator-slice.mjs b/internal/check-mercator-slice.mjs new file mode 100644 index 000000000..89a32a10a --- /dev/null +++ b/internal/check-mercator-slice.mjs @@ -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.') diff --git a/library/composables/poster/useMapPoster.js b/library/composables/poster/useMapPoster.js index 1ca5e9b75..40d97b041 100644 --- a/library/composables/poster/useMapPoster.js +++ b/library/composables/poster/useMapPoster.js @@ -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'); diff --git a/library/maps/mercator-slice.js b/library/maps/mercator-slice.js new file mode 100644 index 000000000..a79a6f4d6 --- /dev/null +++ b/library/maps/mercator-slice.js @@ -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 +} diff --git a/package.json b/package.json index 1fdb573e1..42bde7b5c 100644 --- a/package.json +++ b/package.json @@ -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",