UPG-100 batch selection: on a phone, a selection too big to print asks for a computer

Building a selection's Prayer list or Card deck is never the problem (the whole
selection is ready in under 30 s on a 4×-throttled phone). PRINTING it is: in a
browser capped at 2.5 GB, the print of 1,857 groups (101 sheets, a 138 MB PDF)
finished, and 2,209 groups (113 sheets) crashed the page at 1× and at 4×. A phone
tab has roughly half that memory, so above 1,000 groups (PHONE_GROUP_CEILING,
with the measurement beside it) a phone or tablet gets the house text
"This needs a desktop or laptop computer." (poster.phoneLimitAll, as Country
Posters shows for whole India) and nothing starts. India alone (905) still
prints on a phone. A desktop is never blocked. Copy and CSV stay allowed.

The device test is isPhonePrint(), the same platform test the print path uses.
check:batch-selection-export arm A7 tests the ceiling both ways. Arm C checks that
Prayer list, Card deck and Poster each ask BEFORE they print, and arm D checks
the string ×11. Four mutants each fail.
This commit is contained in:
Doxa Maps 2026-10-01 12:08:38 +00:00
parent 1cd2870941
commit 1fde892d4b
3 changed files with 58 additions and 3 deletions

View file

@ -183,6 +183,21 @@ ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${di
`a state-only selection is titled "Gujarāt"; a state + a country names both; a province with no table is not named; Mizoram alone reads "${empty}"`)
}
// ── A7. A selection too big for a PHONE'S PRINT starts nothing there ────────────────────────────────────
// Printing the whole selection (2,209 groups, 113 sheets) crashed the page in a 2.5 GB browser; 1,857 finished. The
// ceiling (half of that, for a phone tab's memory) is recorded beside its measurement in data/selection-export.js.
// Self-test: a phone over the ceiling is blocked; a desktop over it is not; a phone at or under it (India alone, 905)
// is not. The wiring (arm C) checks each of Prayer list, Card deck and Poster asks BEFORE it prints.
{
const c = X.PHONE_GROUP_CEILING
const src = readFileSync(P('plugin-designs/my-upg-100-list/data/selection-export.js'), 'utf8')
const cases = [[2209, true, true], [1001, true, true], [2209, false, false], [c, true, false], [905, true, false]]
const wrong = cases.filter(([n, phone, want]) => X.overPhoneCeiling(n, phone) !== want).map(([n, phone]) => `${n} on a ${phone ? 'phone' : 'desktop'}`)
ok(c === 1000 && !wrong.length && /2,209 groups +113 sheets +the page CRASHED/.test(src) && /1,857 groups/.test(src),
`phone ceiling ${c}: wrong for ${wrong.join(', ') || '(none)'}; the measurement beside it is ${/2,209 groups/.test(src) ? 'present' : 'MISSING'}`,
`a phone over ${c} groups prints nothing (2,209 → blocked); a desktop is never blocked; India alone (905) still prints on a phone`)
}
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
const rows = []; let row = [], cell = '', q = false
@ -236,6 +251,14 @@ const wired = {
const body = vue.slice(at, vue.indexOf('\n })', at))
return !/toggleTerritory|toggleByProps|toggleZoneSelection/.test(body)
})(),
// Each print action asks the phone ceiling BEFORE it prints, and says so in the house words.
'a big selection on a phone prints nothing': (() => {
const body = (name) => { const at = vue.indexOf(`async function ${name}() {`); return at < 0 ? '' : vue.slice(at, vue.indexOf('\n}\n', at)) }
const before = (name, call) => { const b = body(name); const g = b.indexOf('phoneTooBig('), p = b.indexOf(call); return g > 0 && p > g }
return before('makeSelectionPacket', 'packetPrints.printPacket(') && before('makeSelectionCards', 'packetPrints.printCardDeck(')
&& before('makeSelectionPoster', 'packetPrints.printPoster(') && /flashStatus\(t\('poster\.phoneLimitAll'\)/.test(vue)
&& /const ON_PHONE = isPhonePrint\(\);/.test(vue)
})(),
'the regroup warning on Copy': /const warn = regroupable\(sel\) \? '' : t\('upgMap\.copyNotRegroupable'\)/.test(vue),
// The packet / card-deck title goes through selectionTitleNames (arm A6 proves it), with the keyed empty case.
'the title names come from selectionTitleNames': /const names = selectionTitleNames\(zoneNames, selectedTerritories\.value, from,\s*\n\s*\(\) => keyedError\('wall\.hud\.noRecords'/.test(vue)
@ -249,6 +272,7 @@ ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
const KEYS = {
poster: ['phoneLimitAll'],
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
const bad = []

View file

@ -112,6 +112,23 @@ export function selectionTitleNames(zoneNames, territories, from, noRecords) {
return [...zoneNames, ...countries, ...states];
}
/**
* ★ A SELECTION TOO BIG FOR A PHONE'S PRINT. Building the document is never the problem: on a 4×-throttled phone
* context the WHOLE selection (all 28 territories, 2,209 groups) is ready in 19.5 s as a Prayer list and 27 s as a
* card deck. Printing it is. The print/preview path, measured as page.pdf of the same document in a browser capped
* at 2.5 GB:
* 905 groups (India) 54 sheets 50 MB PDF 45 s at 4×
* 1,324 / 1,497 / 1,857 groups 75 / 83 / 101 sheets 98 / 127 / 138 MB 54-62 s at 1×
* 2,209 groups 113 sheets the page CRASHED (at 1× and at 4×; ~1.8 GB resident when it died)
* A phone tab gets roughly half that memory, so the ceiling is about half the largest that finished: 1,000. India
* alone (905) still prints on a phone; anything bigger asks for a computer, in the words Country Posters uses for
* whole India (poster.phoneLimitAll). Copy and CSV are cheap and stay allowed.
*/
export const PHONE_GROUP_CEILING = 1000;
export function overPhoneCeiling(count, onPhone, ceiling = PHONE_GROUP_CEILING) {
return Boolean(onPhone) && Number(count) > ceiling;
}
/** A file name a partner can recognise: the selection's label, reduced to safe characters. */
export function selectionCsvFileName(label) {
const base = String(label || 'selection').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
@ -119,4 +136,4 @@ export function selectionCsvFileName(label) {
return `upg-100-${base}.csv`;
}
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, settleWithin, asReaderError, selectionTitleNames, selectionCsvFileName };
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, settleWithin, asReaderError, selectionTitleNames, PHONE_GROUP_CEILING, overPhoneCeiling, selectionCsvFileName };

View file

@ -128,7 +128,8 @@ import { colorForZone } from './data/zone-colors.js'
// clicked tapestry feature carries shapeName (ADM1) OR iso3 (ADM0) and this maps
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, settleWithin, asReaderError, selectionTitleNames } from './data/selection-export.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, settleWithin, asReaderError, selectionTitleNames, overPhoneCeiling } from './data/selection-export.js'
import { isPhonePrint } from '@map/composables/printInPlace.js'
import visionFunnel from '../../data/people-groups/vision-funnel.json'
import { upgTotal, countInSentence } from './data/upg-total.js'
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
@ -1998,12 +1999,22 @@ async function exportSelectionCsv() {
} finally { batchBusy.value = false; busyAction.value = ''; }
}
// A selection too big for a phone's print (data/selection-export.js has the measurement) starts nothing and says so.
// The same platform test the print path uses (isPhonePrint: a phone or tablet prints the page, with a phone's memory).
const ON_PHONE = isPhonePrint();
function phoneTooBig(count) {
if (!overPhoneCeiling(count, ON_PHONE)) return false;
flashStatus(t('poster.phoneLimitAll'), 6000);
return true;
}
async function makeSelectionPacket() {
if (batchBusy.value) return;
batchBusy.value = true;
busyAction.value = 'packet';
try {
const sel = await selectionAsList();
if (phoneTooBig(sel.count)) return;
// The packet's map page is per-zone artwork; a multi-zone selection has no single poster to
// lift it from, so it prints a cover WITHOUT the map rather than one zone's map standing in
// for all of them. A packet with no sheet says so on page 1.
@ -2031,7 +2042,9 @@ async function makeSelectionCards() {
batchBusy.value = true;
busyAction.value = 'cards';
try {
await packetPrints.printCardDeck(await selectionAsList());
const sel = await selectionAsList();
if (phoneTooBig(sel.count)) return;
await packetPrints.printCardDeck(sel);
} catch (e) {
flashStatus(t('upgMap.selectionCardsFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
} finally {
@ -2064,6 +2077,7 @@ async function makeSelectionPoster() {
flashStatus(t('messages.posterOneTerritory', { count: lists.length }), 5000);
return;
}
if (phoneTooBig(lists[0].count)) return;
await packetPrints.printPoster(lists[0]);
} catch (e) {
flashStatus(t('messages.selectionPosterFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);