UPG-100 batch selection Copy: a selection apply-grouping cannot regroup in one go (a mix of states, provinces and countries, or a legend zone) still copies, and the flash now says its first line will not regroup. regroupable() is gated against apply-grouping's own dry-run verdict in six cases

This commit is contained in:
Doxa Maps 2026-09-30 22:28:37 +00:00
parent 11dd21b04c
commit 3e5646832d
15 changed files with 68 additions and 18 deletions

View file

@ -86,6 +86,26 @@ ok(dry(formatClipboard(arabic, { color: '#0072b2' })) !== 'ok', 'NEGATIVE CONTRO
const indiaText = X.selectionClipboard([{ key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' }, { key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' }], {})
ok(dry(indiaText) === 'ok', `apply-grouping's dry run refuses an India state selection: ${dry(indiaText)}`, 'an India state selection resolves too (shape names are canonical)')
// ── A3. the client's regroupable() agrees with apply-grouping's own verdict ─────────────────────────────
// Copy warns when its first line will not regroup (a mix of kinds, or a legend zone). The warning is only honest if
// regroupable() says exactly what the tool's dry run says, so each case is run through BOTH.
const T = (key, name, count, kind, country) => ({ key, name, count, kind, ...(country ? { country } : {}) })
const REGROUP_CASES = {
'India states': [T('adm1:Mahārāshtra', 'Mahārāshtra', 54, 'adm1', 'IND'), T('adm1:Goa', 'Goa', 5, 'adm1', 'IND')],
'a China province': [T('adm1:Gansu Province', 'Gansu Province', 3, 'adm1', 'CHN')],
'countries': [T('adm0:NPL', 'Nepal', 34, 'adm0'), T('adm0:BTN', 'Bhutan', 13, 'adm0')],
'a state + a country': [T('adm1:Goa', 'Goa', 5, 'adm1', 'IND'), T('adm0:NPL', 'Nepal', 34, 'adm0')],
'India + China': [T('adm1:Goa', 'Goa', 5, 'adm1', 'IND'), T('adm1:Gansu Province', 'Gansu Province', 3, 'adm1', 'CHN')],
'a legend zone': [T('zone:test', 'Test Zone', 12, 'zone')],
}
const disagree = []
for (const [name, sel] of Object.entries(REGROUP_CASES)) {
const tool = dry(X.selectionClipboard(sel, {})) === 'ok'
if (X.regroupable(sel) !== tool) disagree.push(`${name}: regroupable ${X.regroupable(sel)}, apply-grouping ${tool ? 'accepts' : 'refuses'}`)
}
ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${disagree.join('; ')}`,
`the regroup warning matches apply-grouping's own verdict in all ${Object.keys(REGROUP_CASES).length} cases (India, China, countries, and the three it refuses)`)
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
const rows = []; let row = [], cell = '', q = false
@ -124,6 +144,7 @@ const wired = {
'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue),
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue),
// India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected
'the regroup warning on Copy': /const warn = regroupable\(sel\) \? '' : t\('upgMap\.copyNotRegroupable'\)/.test(vue),
'India states detected from the served table': /import \{ loadIndiaStates \} from '@map\/data\/indiaStates\.js'/.test(vue)
&& /india\.byShapeName\.get\(t\.name\)/.test(vue) && /t\.kind === 'adm1'\) indiaStates\(\)\.catch/.test(vue),
}
@ -133,7 +154,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 = {
upgMap: ['copySelection', 'exportCsv', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
const bad = []
for (const [f, keys] of Object.entries(KEYS)) {

View file

@ -14,5 +14,6 @@
"csvSaved": "تم حفظ CSV — الشعوب: {count}",
"csvFailed": "فشل تصدير CSV: {reason}",
"copySelection": "نسخ",
"exportCsv": "تصدير CSV"
"exportCsv": "تصدير CSV",
"copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV gespeichert — Volksgruppen: {count}",
"csvFailed": "CSV-Export fehlgeschlagen: {reason}",
"copySelection": "Kopieren",
"exportCsv": "CSV exportieren"
"exportCsv": "CSV exportieren",
"copyNotRegroupable": "Nicht gemeinsam umgruppierbar: Bundesstaaten oder Provinzen eines Landes oder die Länder getrennt kopieren"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV saved — people groups: {count}",
"csvFailed": "CSV failed: {reason}",
"copySelection": "Copy",
"exportCsv": "Export CSV"
"exportCsv": "Export CSV",
"copyNotRegroupable": "Can't be regrouped together: copy one country's states or provinces, or the countries, separately"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV guardado — pueblos: {count}",
"csvFailed": "Error al exportar el CSV: {reason}",
"copySelection": "Copiar",
"exportCsv": "Exportar CSV"
"exportCsv": "Exportar CSV",
"copyNotRegroupable": "No se pueden reagrupar juntos: copia por separado los estados o provincias de un solo país, o los países"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV enregistré — peuples : {count}",
"csvFailed": "Échec de l’export CSV : {reason}",
"copySelection": "Copier",
"exportCsv": "Exporter en CSV"
"exportCsv": "Exporter en CSV",
"copyNotRegroupable": "Impossible de les regrouper ensemble : copiez séparément les États ou provinces d’un seul pays, ou les pays"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV सहेजा गया — जनसमूह: {count}",
"csvFailed": "CSV निर्यात विफल: {reason}",
"copySelection": "कॉपी करें",
"exportCsv": "CSV निर्यात करें"
"exportCsv": "CSV निर्यात करें",
"copyNotRegroupable": "इन्हें एक साथ पुनर्समूहित नहीं किया जा सकता: एक देश के राज्य या प्रांत, या देश, अलग-अलग कॉपी करें"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV salvato — popoli: {count}",
"csvFailed": "Esportazione CSV non riuscita: {reason}",
"copySelection": "Copia",
"exportCsv": "Esporta CSV"
"exportCsv": "Esporta CSV",
"copyNotRegroupable": "Non si possono raggruppare insieme: copia separatamente gli stati o le province di un solo paese, o i paesi"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV salvo — povos: {count}",
"csvFailed": "Falha ao exportar o CSV: {reason}",
"copySelection": "Copiar",
"exportCsv": "Exportar CSV"
"exportCsv": "Exportar CSV",
"copyNotRegroupable": "Não podem ser reagrupados juntos: copie separadamente os estados ou províncias de um só país, ou os países"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV salvat — popoare: {count}",
"csvFailed": "Exportul CSV a eșuat: {reason}",
"copySelection": "Copiază",
"exportCsv": "Exportă CSV"
"exportCsv": "Exportă CSV",
"copyNotRegroupable": "Nu pot fi regrupate împreună: copiază separat statele sau provinciile unei singure țări, sau țările"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV сохранён — народы: {count}",
"csvFailed": "Не удалось экспортировать CSV: {reason}",
"copySelection": "Копировать",
"exportCsv": "Экспорт в CSV"
"exportCsv": "Экспорт в CSV",
"copyNotRegroupable": "Нельзя перегруппировать вместе: копируйте отдельно штаты или провинции одной страны либо страны"
}

View file

@ -14,5 +14,6 @@
"csvSaved": "CSV 已保存 — 族群:{count}",
"csvFailed": "CSV 导出失败:{reason}",
"copySelection": "复制",
"exportCsv": "导出 CSV"
"exportCsv": "导出 CSV",
"copyNotRegroupable": "无法一起重新分组:请分别复制同一国家的邦或省,或各个国家"
}

View file

@ -50,6 +50,20 @@ export function payloadName(t) {
return t ? t.name : '';
}
// apply-grouping regroups ONE kind at a time: India's states, China's provinces, or countries. A mix of kinds is
// refused ("a zone must be all-India, all-China, or all-uncovered"), and so is a legend zone's name (it is not a
// territory). Copy still copies such a selection; the flash says its first line will not regroup.
export function regroupKind(t) {
if (!t) return 'unknown';
if (t.kind === 'adm0') return 'uncovered';
if (t.kind === 'adm1') return t.country === 'CHN' ? 'china' : t.country === 'IND' ? 'india' : 'unknown';
return 'zone';
}
export function regroupable(selection) {
const kinds = new Set((selection || []).map(regroupKind));
return kinds.size === 1 && !kinds.has('zone') && !kinds.has('unknown');
}
/** The clipboard text: apply-grouping's line (machine names), the reader's breakdown, then the CSV after a blank line. */
export function selectionClipboard(selection, opts = {}, csv = '') {
const color = opts && opts.color ? opts.color : '';
@ -67,4 +81,4 @@ export function selectionCsvFileName(label) {
return `upg-100-${base}.csv`;
}
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, selectionCsv, selectionClipboard, selectionCsvFileName };
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, selectionCsvFileName };

View file

@ -158,7 +158,8 @@ export function resolveTerritory(props, opts = {}) {
const name = String(props.shapeName);
const count = Object.prototype.hasOwnProperty.call(ADM1_COUNTS, name)
? ADM1_COUNTS[name] : null;
return { key: 'adm1:' + name, name, count, kind: 'adm1' };
// country: which ADM1 set it belongs to (IND / CHN), because data/apply-grouping.mjs regroups one kind at a time.
return { key: 'adm1:' + name, name, count, kind: 'adm1', country: props.shapeGroup || props.iso3 || null };
}
// (2) ADM0 — country (only iso3 on the feature)

View file

@ -128,7 +128,7 @@ 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 } from './data/selection-export.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable } from './data/selection-export.js'
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
// tokenized contains matcher. Pure data + pure fns; this profile builds the rows
// from the live records (PG), the legend leafZones (zone), and admin-counts +
@ -1923,10 +1923,13 @@ function copySelection() {
return selectionClipboard(sel, { color }, selectionCsv(p.records, p.from, { color }));
}, (e) => { note = errorText(e, t); return selectionClipboard(sel, { color }); });
text.then((v) => { try { window.__UPG_LAST_CLIPBOARD__ = v } catch (_) {} });
// A selection apply-grouping cannot regroup in one go (a mix of kinds, or a legend zone) still copies; the
// flash says so, so a partner does not hand an agent a line it will refuse.
const warn = regroupable(sel) ? '' : t('upgMap.copyNotRegroupable');
writeClipboard(text).then((ok) => {
const done = count == null ? t('upgMap.copiedTerritoriesOnly', { reason: note.slice(0, 90) })
: t('upgMap.copiedSelection', { count }) + (note ? ' · ' + note.slice(0, 90) : '');
flashRef(copyFlash, ok ? done : t('messages.copyFailed'), ok && note ? 6000 : 3000);
const done = (count == null ? t('upgMap.copiedTerritoriesOnly', { reason: note.slice(0, 90) })
: t('upgMap.copiedSelection', { count }) + (note ? ' · ' + note.slice(0, 90) : '')) + (warn ? ' · ' + warn : '');
flashRef(copyFlash, ok ? done : t('messages.copyFailed'), ok && (note || warn) ? 6000 : 3000);
});
}
async function writeClipboard(textPromise) {