From 3e5646832ded10891e374ff2bd1d0315c3bbde1b Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 22:28:37 +0000 Subject: [PATCH] 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 --- internal/check-batch-selection-export.mjs | 23 ++++++++++++++++++- library/i18n/locales/ar/upgMap.json | 3 ++- library/i18n/locales/de/upgMap.json | 3 ++- library/i18n/locales/en/upgMap.json | 3 ++- library/i18n/locales/es/upgMap.json | 3 ++- library/i18n/locales/fr/upgMap.json | 3 ++- library/i18n/locales/hi/upgMap.json | 3 ++- library/i18n/locales/it/upgMap.json | 3 ++- library/i18n/locales/pt/upgMap.json | 3 ++- library/i18n/locales/ro/upgMap.json | 3 ++- library/i18n/locales/ru/upgMap.json | 3 ++- library/i18n/locales/zh/upgMap.json | 3 ++- .../my-upg-100-list/data/selection-export.js | 16 ++++++++++++- .../my-upg-100-list/data/territory-counts.js | 3 ++- .../my-upg-100-list/upg-100-list.vue | 11 +++++---- 15 files changed, 68 insertions(+), 18 deletions(-) diff --git a/internal/check-batch-selection-export.mjs b/internal/check-batch-selection-export.mjs index 0af60dc12..4429205cd 100644 --- a/internal/check-batch-selection-export.mjs +++ b/internal/check-batch-selection-export.mjs @@ -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)) { diff --git a/library/i18n/locales/ar/upgMap.json b/library/i18n/locales/ar/upgMap.json index ea9b380a3..2a5e4a78a 100644 --- a/library/i18n/locales/ar/upgMap.json +++ b/library/i18n/locales/ar/upgMap.json @@ -14,5 +14,6 @@ "csvSaved": "تم حفظ CSV — الشعوب: {count}", "csvFailed": "فشل تصدير CSV: {reason}", "copySelection": "نسخ", - "exportCsv": "تصدير CSV" + "exportCsv": "تصدير CSV", + "copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة" } diff --git a/library/i18n/locales/de/upgMap.json b/library/i18n/locales/de/upgMap.json index fe82f8891..fc039a9ab 100644 --- a/library/i18n/locales/de/upgMap.json +++ b/library/i18n/locales/de/upgMap.json @@ -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" } diff --git a/library/i18n/locales/en/upgMap.json b/library/i18n/locales/en/upgMap.json index 33228816c..c3c6196b9 100644 --- a/library/i18n/locales/en/upgMap.json +++ b/library/i18n/locales/en/upgMap.json @@ -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" } diff --git a/library/i18n/locales/es/upgMap.json b/library/i18n/locales/es/upgMap.json index ce928be87..7b06a6f27 100644 --- a/library/i18n/locales/es/upgMap.json +++ b/library/i18n/locales/es/upgMap.json @@ -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" } diff --git a/library/i18n/locales/fr/upgMap.json b/library/i18n/locales/fr/upgMap.json index ccd896ed5..c7c6f301c 100644 --- a/library/i18n/locales/fr/upgMap.json +++ b/library/i18n/locales/fr/upgMap.json @@ -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" } diff --git a/library/i18n/locales/hi/upgMap.json b/library/i18n/locales/hi/upgMap.json index ecdfead9f..d5d62cf36 100644 --- a/library/i18n/locales/hi/upgMap.json +++ b/library/i18n/locales/hi/upgMap.json @@ -14,5 +14,6 @@ "csvSaved": "CSV सहेजा गया — जनसमूह: {count}", "csvFailed": "CSV निर्यात विफल: {reason}", "copySelection": "कॉपी करें", - "exportCsv": "CSV निर्यात करें" + "exportCsv": "CSV निर्यात करें", + "copyNotRegroupable": "इन्हें एक साथ पुनर्समूहित नहीं किया जा सकता: एक देश के राज्य या प्रांत, या देश, अलग-अलग कॉपी करें" } diff --git a/library/i18n/locales/it/upgMap.json b/library/i18n/locales/it/upgMap.json index 04895adc1..fa29fac20 100644 --- a/library/i18n/locales/it/upgMap.json +++ b/library/i18n/locales/it/upgMap.json @@ -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" } diff --git a/library/i18n/locales/pt/upgMap.json b/library/i18n/locales/pt/upgMap.json index e750d6b3e..9644486bf 100644 --- a/library/i18n/locales/pt/upgMap.json +++ b/library/i18n/locales/pt/upgMap.json @@ -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" } diff --git a/library/i18n/locales/ro/upgMap.json b/library/i18n/locales/ro/upgMap.json index 44dc06147..3d2935d56 100644 --- a/library/i18n/locales/ro/upgMap.json +++ b/library/i18n/locales/ro/upgMap.json @@ -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" } diff --git a/library/i18n/locales/ru/upgMap.json b/library/i18n/locales/ru/upgMap.json index b42eddc29..8334c7c75 100644 --- a/library/i18n/locales/ru/upgMap.json +++ b/library/i18n/locales/ru/upgMap.json @@ -14,5 +14,6 @@ "csvSaved": "CSV сохранён — народы: {count}", "csvFailed": "Не удалось экспортировать CSV: {reason}", "copySelection": "Копировать", - "exportCsv": "Экспорт в CSV" + "exportCsv": "Экспорт в CSV", + "copyNotRegroupable": "Нельзя перегруппировать вместе: копируйте отдельно штаты или провинции одной страны либо страны" } diff --git a/library/i18n/locales/zh/upgMap.json b/library/i18n/locales/zh/upgMap.json index 7756e0874..a7b3d1f35 100644 --- a/library/i18n/locales/zh/upgMap.json +++ b/library/i18n/locales/zh/upgMap.json @@ -14,5 +14,6 @@ "csvSaved": "CSV 已保存 — 族群:{count}", "csvFailed": "CSV 导出失败:{reason}", "copySelection": "复制", - "exportCsv": "导出 CSV" + "exportCsv": "导出 CSV", + "copyNotRegroupable": "无法一起重新分组:请分别复制同一国家的邦或省,或各个国家" } diff --git a/plugin-designs/my-upg-100-list/data/selection-export.js b/plugin-designs/my-upg-100-list/data/selection-export.js index c163b66f9..b8c96d7f9 100644 --- a/plugin-designs/my-upg-100-list/data/selection-export.js +++ b/plugin-designs/my-upg-100-list/data/selection-export.js @@ -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 }; diff --git a/plugin-designs/my-upg-100-list/data/territory-counts.js b/plugin-designs/my-upg-100-list/data/territory-counts.js index d880ff964..fa719b7e2 100644 --- a/plugin-designs/my-upg-100-list/data/territory-counts.js +++ b/plugin-designs/my-upg-100-list/data/territory-counts.js @@ -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) diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index af5af4fcc..bb5f09d34 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -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) {