mapshaper 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +1 -1
  2. package/bin/mapshaper-gui +31 -7
  3. package/mapshaper.js +45 -42
  4. package/package.json +8 -7
  5. package/www/geopackage.js +10 -10
  6. package/www/index.html +32 -4
  7. package/www/mapshaper-gui.js +529 -83
  8. package/www/mapshaper.js +45 -42
  9. package/www/modules.js +5 -5
  10. package/www/page.css +157 -0
  11. package/www/docs/_assets/cmd-search.js +0 -213
  12. package/www/docs/_assets/docs.css +0 -722
  13. package/www/docs/_assets/docs.js +0 -75
  14. package/www/docs/_assets/highlight.css +0 -10
  15. package/www/docs/essentials/command-line.html +0 -127
  16. package/www/docs/essentials/command-line.html.md +0 -112
  17. package/www/docs/essentials/web-app.html +0 -138
  18. package/www/docs/essentials/web-app.html.md +0 -106
  19. package/www/docs/examples/basics.html +0 -275
  20. package/www/docs/examples/basics.html.md +0 -370
  21. package/www/docs/examples/data/Makefile +0 -31
  22. package/www/docs/examples/data/globe.msx +0 -0
  23. package/www/docs/examples/data/globe.svg +0 -616
  24. package/www/docs/examples/data/globe.txt +0 -21
  25. package/www/docs/examples/data/globe.zip +0 -0
  26. package/www/docs/examples/data/ne_50m_admin_0_countries.geojson +0 -1
  27. package/www/docs/examples/data/ne_50m_admin_1_states_provinces_lakes.geojson +0 -1
  28. package/www/docs/examples/data/us-states.msx +0 -0
  29. package/www/docs/examples/data/us-states.svg +0 -56
  30. package/www/docs/examples/data/us-states.txt +0 -6
  31. package/www/docs/examples/data/us-states.zip +0 -0
  32. package/www/docs/examples/globe.html +0 -108
  33. package/www/docs/examples/globe.html.md +0 -64
  34. package/www/docs/examples/us-states.html +0 -88
  35. package/www/docs/examples/us-states.html.md +0 -44
  36. package/www/docs/formats/csv.html +0 -127
  37. package/www/docs/formats/csv.html.md +0 -97
  38. package/www/docs/formats/dbf.html +0 -87
  39. package/www/docs/formats/dbf.html.md +0 -39
  40. package/www/docs/formats/flatgeobuf.html +0 -85
  41. package/www/docs/formats/flatgeobuf.html.md +0 -41
  42. package/www/docs/formats/geojson.html +0 -107
  43. package/www/docs/formats/geojson.html.md +0 -65
  44. package/www/docs/formats/geopackage.html +0 -87
  45. package/www/docs/formats/geopackage.html.md +0 -42
  46. package/www/docs/formats/json.html +0 -83
  47. package/www/docs/formats/json.html.md +0 -35
  48. package/www/docs/formats/kml.html +0 -82
  49. package/www/docs/formats/kml.html.md +0 -39
  50. package/www/docs/formats/overview.html +0 -191
  51. package/www/docs/formats/overview.html.md +0 -34
  52. package/www/docs/formats/shapefile.html +0 -123
  53. package/www/docs/formats/shapefile.html.md +0 -84
  54. package/www/docs/formats/snapshot.html +0 -87
  55. package/www/docs/formats/snapshot.html.md +0 -39
  56. package/www/docs/formats/svg.html +0 -99
  57. package/www/docs/formats/svg.html.md +0 -51
  58. package/www/docs/formats/topojson.html +0 -102
  59. package/www/docs/formats/topojson.html.md +0 -54
  60. package/www/docs/gallery/index.html +0 -80
  61. package/www/docs/gallery/index.html.md +0 -29
  62. package/www/docs/guides/combining-layers.html +0 -109
  63. package/www/docs/guides/combining-layers.html.md +0 -89
  64. package/www/docs/guides/expressions.html +0 -600
  65. package/www/docs/guides/expressions.html.md +0 -376
  66. package/www/docs/guides/programmatic.html +0 -117
  67. package/www/docs/guides/programmatic.html.md +0 -91
  68. package/www/docs/guides/projections.html +0 -158
  69. package/www/docs/guides/projections.html.md +0 -118
  70. package/www/docs/guides/simplification.html +0 -110
  71. package/www/docs/guides/simplification.html.md +0 -96
  72. package/www/docs/guides/topology.html +0 -90
  73. package/www/docs/guides/topology.html.md +0 -63
  74. package/www/docs/images/cal-counties.png +0 -0
  75. package/www/docs/images/simplification-detail.png +0 -0
  76. package/www/docs/images/simplification-dp.png +0 -0
  77. package/www/docs/images/simplification-mod2.png +0 -0
  78. package/www/docs/images/tiger-counties.png +0 -0
  79. package/www/docs/index.html +0 -101
  80. package/www/docs/index.html.md +0 -59
  81. package/www/docs/reference.html +0 -1302
  82. package/www/docs/reference.html.md +0 -1817
  83. package/www/docs/whats-new.html +0 -77
  84. package/www/docs/whats-new.html.md +0 -59
  85. package/www/llms-full.txt +0 -4053
  86. package/www/llms.txt +0 -55
@@ -1418,6 +1418,7 @@
1418
1418
  //
1419
1419
  var options = getSaveFileOptions(filename);
1420
1420
  var handle;
1421
+ done = done || function() {};
1421
1422
  try {
1422
1423
  handle = await window.showSaveFilePicker(options);
1423
1424
  var writable = await handle.createWritable();
@@ -1519,13 +1520,36 @@
1519
1520
  // https://github.com/jakearchibald/idb-keyval
1520
1521
  var sessionId = getUniqId('session');
1521
1522
  var snapshotCount = 0;
1523
+ // IDs of snapshots created (and not removed) by this tab. Tracked in memory
1524
+ // so the pagehide handler can fire a single batched delMany() without first
1525
+ // awaiting idb.keys() -- the page may not survive the round trip.
1526
+ var ownSnapshotIds = new Set();
1527
+
1528
+ // Lifecycle constants for snapshot cleanup.
1529
+ // HEARTBEAT_INTERVAL_MS: how often this tab refreshes its localStorage entry.
1530
+ // STALE_THRESHOLD_MS: a session whose heartbeat is older than this is treated
1531
+ // as dead. Generous enough to tolerate backgrounded/throttled tabs.
1532
+ // BROADCAST_DISCOVERY_MS: how long startup waits for live tabs to identify
1533
+ // themselves over BroadcastChannel before deciding what to delete.
1534
+ var HEARTBEAT_INTERVAL_MS = 30 * 1000;
1535
+ var STALE_THRESHOLD_MS = 5 * 60 * 1000;
1536
+ var BROADCAST_DISCOVERY_MS = 200;
1537
+ var SESSION_DATA_KEY = 'session_data';
1538
+ var BROADCAST_CHANNEL_NAME = 'mapshaper-snapshots';
1522
1539
 
1523
1540
  function getUniqId(prefix) {
1524
1541
  return prefix + '_' + (Math.random() + 1).toString(36).substring(2,8);
1525
1542
  }
1526
1543
 
1544
+ function getSessionFromSnapshotId(snapshotId) {
1545
+ // Snapshot ids look like 'session_<6chars>_<NNN>'. The session id is the
1546
+ // 'session_<6chars>' prefix.
1547
+ var m = /^(session_[a-z0-9]+)_\d+$/.exec(snapshotId);
1548
+ return m ? m[1] : null;
1549
+ }
1550
+
1527
1551
  function isSnapshotId(str) {
1528
- return /^session_/.test(str);
1552
+ return getSessionFromSnapshotId(str) !== null;
1529
1553
  }
1530
1554
 
1531
1555
  function SessionSnapshots(gui) {
@@ -1543,13 +1567,20 @@
1543
1567
  return;
1544
1568
  }
1545
1569
  menu = El('div').addClass('nav-sub-menu save-menu').appendTo(btn.node());
1570
+ startLifecycle();
1546
1571
  await initialCleanup();
1547
1572
 
1548
- window.addEventListener('beforeunload', async function() {
1549
- // delete snapshot data
1550
- // This is not ideal, because the data gets deleted even if the user
1551
- // cancels the page close... but there's no apparent good alternative
1552
- await finalCleanup();
1573
+ // 'pagehide' is more reliable than 'unload' across modern browsers
1574
+ // (Chrome's bfcache rules increasingly suppress 'unload'). Sync work
1575
+ // (localStorage write, BroadcastChannel notice) always completes; the
1576
+ // best-effort delMany() below frequently completes in Chrome/Firefox and
1577
+ // sometimes in Safari, but is not relied upon -- the next session's
1578
+ // startup cleanup is the safety net.
1579
+ window.addEventListener('pagehide', function(e) {
1580
+ if (e.persisted) return; // bfcache: tab may come back, leave entry alive
1581
+ removeOwnSession();
1582
+ announceLeaving();
1583
+ attemptOwnDataDeletion();
1553
1584
  });
1554
1585
 
1555
1586
  btn.on('mouseenter', function() {
@@ -1573,12 +1604,6 @@
1573
1604
  var snapshots = await fetchSnapshotList();
1574
1605
 
1575
1606
  menu.empty();
1576
- addMenuLink({
1577
- slug: 'stash',
1578
- // label: 'save data snapshot',
1579
- label: 'create a snapshot',
1580
- action: saveSnapshot
1581
- });
1582
1607
 
1583
1608
  if (!gui.session.isEmpty()) {
1584
1609
  // Surface the console "history" command via the snapshot menu so users
@@ -1593,6 +1618,15 @@
1593
1618
  });
1594
1619
  }
1595
1620
 
1621
+ addMenuLink({
1622
+ slug: 'stash',
1623
+ // label: 'save data snapshot',
1624
+ label: 'create a snapshot',
1625
+ action: saveSnapshot
1626
+ });
1627
+
1628
+
1629
+
1596
1630
  // var available = await getAvailableStorage();
1597
1631
  // if (available) {
1598
1632
  // El('div').addClass('save-menu-entry').text(available + ' available').appendTo(menu);
@@ -1676,6 +1710,7 @@
1676
1710
  };
1677
1711
 
1678
1712
  await idb.set(entry.id, obj);
1713
+ ownSnapshotIds.add(entry.id);
1679
1714
  await addToIndex(entry);
1680
1715
  renderMenu();
1681
1716
  }
@@ -1690,7 +1725,7 @@
1690
1725
  }
1691
1726
 
1692
1727
  async function fetchSnapshotList() {
1693
- await removeMissingSnapshots();
1728
+ await pruneIndexAgainstKeys();
1694
1729
  var index = await fetchIndex();
1695
1730
  var snapshots = index.snapshots;
1696
1731
  snapshots = snapshots.filter(function(o) {return o.session == sessionId;});
@@ -1699,6 +1734,7 @@
1699
1734
 
1700
1735
  async function removeSnapshotById(id, gui) {
1701
1736
  await idb.del(id);
1737
+ ownSnapshotIds.delete(id);
1702
1738
  return updateIndex(function(index) {
1703
1739
  index.snapshots = index.snapshots.filter(function(snap) {
1704
1740
  return snap.id != id;
@@ -1796,13 +1832,16 @@
1796
1832
  }
1797
1833
 
1798
1834
  async function addToIndex(obj) {
1799
- updateSessionData();
1835
+ touchOwnSession();
1800
1836
  return updateIndex(function(index) {
1801
1837
  index.snapshots.push(obj);
1802
1838
  });
1803
1839
  }
1804
1840
 
1805
- async function removeMissingSnapshots() {
1841
+ // Drop index entries whose underlying IndexedDB blob has gone missing
1842
+ // (e.g. cleared by another tab). Cheaper than reclaimDeadSessionData; used
1843
+ // before rendering the menu to keep stale entries out of the UI.
1844
+ async function pruneIndexAgainstKeys() {
1806
1845
  var keys = await idb.keys();
1807
1846
  return updateIndex(function(index) {
1808
1847
  index.snapshots = index.snapshots.filter(function(snap) {
@@ -1811,32 +1850,65 @@
1811
1850
  });
1812
1851
  }
1813
1852
 
1853
+ // Run on every fresh page load. Aggressively reclaim space by deleting any
1854
+ // snapshot whose owning session is no longer alive.
1814
1855
  async function initialCleanup() {
1815
- // (Safari workaround) remove any lingering data from past sessions
1816
- if (getSessionData().length === 0) {
1817
- await idb.clear();
1818
- }
1819
- // remove any snapshots that are not indexed
1856
+ touchOwnSession();
1857
+ pruneStaleSessionData();
1858
+ var liveSessions = await discoverLiveSessions();
1859
+ await reclaimDeadSessionData(liveSessions);
1860
+ }
1861
+
1862
+ // Delete every snapshot in IndexedDB whose session id is not in liveSessions,
1863
+ // and keep the on-disk index consistent with the actual key set.
1864
+ async function reclaimDeadSessionData(liveSessions) {
1820
1865
  var keys = await idb.keys();
1821
- var indexedIds = (await fetchIndex()).snapshots.map(function(snap) {return snap.id;});
1866
+ var doomedKeys = [];
1867
+ var doomedSessions = new Set();
1822
1868
  keys.forEach(function(key) {
1823
- if (isSnapshotId(key) && !indexedIds.includes(key)) {
1824
- idb.del(key);
1869
+ var sid = getSessionFromSnapshotId(key);
1870
+ if (sid && !liveSessions.has(sid)) {
1871
+ doomedKeys.push(key);
1872
+ doomedSessions.add(sid);
1825
1873
  }
1826
1874
  });
1827
- // remove old indexed snapshots
1875
+
1876
+ // Sum sizes from the index for an informative log message. We only know
1877
+ // the size of snapshots that still have an index entry; orphaned blobs
1878
+ // are counted but their sizes contribute 0.
1879
+ var sizeBytes = 0;
1880
+ if (doomedKeys.length) {
1881
+ var index = await fetchIndex();
1882
+ var doomedKeySet = new Set(doomedKeys);
1883
+ index.snapshots.forEach(function(snap) {
1884
+ if (doomedKeySet.has(snap.id) && typeof snap.size === 'number') {
1885
+ sizeBytes += snap.size;
1886
+ }
1887
+ });
1888
+ await Promise.all(doomedKeys.map(function(k) { return idb.del(k); }));
1889
+ }
1890
+
1891
+ // Drop index entries pointing to deleted snapshots, and any entries whose
1892
+ // session is dead even if the underlying key was already gone.
1893
+ var remainingKeys = await idb.keys();
1894
+ var keySet = new Set(remainingKeys);
1828
1895
  await updateIndex(function(index) {
1829
1896
  index.snapshots = index.snapshots.filter(function(snap) {
1830
- var msPerDay = 1000 * 60 * 60 * 24;
1831
- var daysOld = (Date.now() - snap.created) / msPerDay;
1832
- if (daysOld > 1) {
1833
- if (keys.includes(snap.id)) idb.del(snap.id);
1834
- return false;
1835
- }
1836
- return true;
1897
+ if (!keySet.has(snap.id)) return false;
1898
+ var sid = getSessionFromSnapshotId(snap.id);
1899
+ return sid && liveSessions.has(sid);
1837
1900
  });
1838
- return index;
1839
1901
  });
1902
+
1903
+ if (doomedKeys.length) {
1904
+ var msg = '[mapshaper] startup cleanup reclaimed ' +
1905
+ doomedKeys.length + ' snapshot' + (doomedKeys.length === 1 ? '' : 's') +
1906
+ ' from ' + doomedSessions.size + ' stale session' +
1907
+ (doomedSessions.size === 1 ? '' : 's');
1908
+ var sizeStr = sizeBytes > 0 ? formatSize(sizeBytes) : '';
1909
+ if (sizeStr) msg += ' (' + sizeStr + ')';
1910
+ console.log(msg);
1911
+ }
1840
1912
  }
1841
1913
 
1842
1914
  async function getAvailableStorage() {
@@ -1874,52 +1946,164 @@
1874
1946
  return target;
1875
1947
  }
1876
1948
 
1877
- // Clean up snapshot data (called just before browser tab is closed)
1878
- async function finalCleanup() {
1879
- // When called on 'beforeunload', idb.clear() seems to complete
1880
- // before tab is unloaded in Chrome and Firefox, but not in Safari.
1881
- // Calling idb.del(key) to selectively delete data for the current session
1882
- // does not seem to complete in any browser.
1883
- // So we wait until the last open session is ending at this URL, and delete
1884
- // data for all recently open sessions.
1885
- //
1886
- var sessions = getSessionData().filter(function(item) {
1887
- // remove current session
1888
- var daysOld = (Date.now() - item.timestamp) / (1000 * 60 * 60 * 24);
1889
- if (item.session == sessionId) return false;
1890
- // also remove any lingering old sessions (ordinarily this shouldn't be needed)
1891
- if (daysOld > 1) return false;
1892
- return true;
1949
+ // Heartbeat + BroadcastChannel state. The localStorage map and the channel
1950
+ // together let other tabs identify themselves on demand and let crashed tabs'
1951
+ // data be reclaimed safely.
1952
+ //
1953
+ // localStorage 'session_data' shape: { <sessionId>: <lastSeenMs>, ... }
1954
+ // (The previous build stored an array; old data is overwritten on first
1955
+ // touchOwnSession() call. Only used for cleanup heuristics, not user data.)
1956
+
1957
+ var _heartbeatTimer = null;
1958
+ var _channel = null;
1959
+
1960
+ function startLifecycle() {
1961
+ touchOwnSession();
1962
+ _heartbeatTimer = setInterval(touchOwnSession, HEARTBEAT_INTERVAL_MS);
1963
+ if (typeof BroadcastChannel == 'function') {
1964
+ try {
1965
+ _channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
1966
+ _channel.onmessage = function(e) {
1967
+ var msg = e.data;
1968
+ if (!msg || msg.from === sessionId) return;
1969
+ if (msg.type === 'whois') {
1970
+ try {
1971
+ _channel.postMessage({type: 'iam', from: sessionId});
1972
+ } catch (err) {} // channel can throw if tab is being torn down
1973
+ }
1974
+ };
1975
+ } catch (err) {
1976
+ _channel = null; // sandboxed contexts may forbid BroadcastChannel
1977
+ }
1978
+ }
1979
+ }
1980
+
1981
+ // Discover other live tabs. Returns a Set of session ids that should be
1982
+ // considered alive (always includes our own).
1983
+ function discoverLiveSessions() {
1984
+ return new Promise(function(resolve) {
1985
+ var live = new Set([sessionId]);
1986
+ // localStorage heartbeat data is the durable signal; it survives
1987
+ // backgrounded/throttled tabs that may not respond to BroadcastChannel
1988
+ // promptly.
1989
+ var data = readSessionData();
1990
+ Object.keys(data).forEach(function(sid) {
1991
+ if (Date.now() - data[sid] < STALE_THRESHOLD_MS) {
1992
+ live.add(sid);
1993
+ }
1994
+ });
1995
+ if (!_channel) {
1996
+ resolve(live);
1997
+ return;
1998
+ }
1999
+ // Broadcast 'whois' and add any tab that responds within the discovery
2000
+ // window. This is fast and authoritative for foreground tabs.
2001
+ var listener = function(e) {
2002
+ var msg = e.data;
2003
+ if (msg && msg.type === 'iam' && msg.from && msg.from !== sessionId) {
2004
+ live.add(msg.from);
2005
+ }
2006
+ };
2007
+ _channel.addEventListener('message', listener);
2008
+ try {
2009
+ _channel.postMessage({type: 'whois', from: sessionId});
2010
+ } catch (err) {}
2011
+ setTimeout(function() {
2012
+ _channel.removeEventListener('message', listener);
2013
+ resolve(live);
2014
+ }, BROADCAST_DISCOVERY_MS);
1893
2015
  });
1894
- setSessionData(sessions);
1895
- if (sessions.length === 0) {
1896
- await idb.clear();
2016
+ }
2017
+
2018
+ function announceLeaving() {
2019
+ if (!_channel) return;
2020
+ try {
2021
+ _channel.postMessage({type: 'leaving', from: sessionId});
2022
+ _channel.close();
2023
+ } catch (err) {}
2024
+ }
2025
+
2026
+ // Best-effort eager cleanup at end-of-session. Snapshots can be tens or
2027
+ // hundreds of MB, so we don't want to rely solely on the next session's
2028
+ // startup to reclaim space. A single delMany() transaction is cheap to
2029
+ // launch and frequently commits before the page is fully torn down,
2030
+ // especially in Chrome/Firefox; Safari is less reliable. Anything that
2031
+ // doesn't complete here will be reclaimed by the next session anyway.
2032
+ //
2033
+ // Important: do NOT await any of this. The browser doesn't await async work
2034
+ // in the unload path; we just want the IDB transaction to be queued before
2035
+ // the page is killed.
2036
+ function attemptOwnDataDeletion() {
2037
+ if (ownSnapshotIds.size === 0) return;
2038
+ var ids = Array.from(ownSnapshotIds);
2039
+ ownSnapshotIds.clear();
2040
+ // Delete the blobs in a single transaction.
2041
+ try {
2042
+ idb.delMany(ids).catch(function() {});
2043
+ } catch (err) {}
2044
+ // Drop our entries from the index in a separate (also fire-and-forget)
2045
+ // transaction. If only one of the two completes, startup cleanup will
2046
+ // reconcile -- reclaimDeadSessionData() handles both "key gone, index
2047
+ // entry remains" and "index entry gone, key remains".
2048
+ try {
2049
+ updateIndex(function(index) {
2050
+ index.snapshots = index.snapshots.filter(function(snap) {
2051
+ return getSessionFromSnapshotId(snap.id) !== sessionId;
2052
+ });
2053
+ }).catch(function() {});
2054
+ } catch (err) {}
2055
+ }
2056
+
2057
+ function touchOwnSession() {
2058
+ var data = readSessionData();
2059
+ data[sessionId] = Date.now();
2060
+ writeSessionData(data);
2061
+ }
2062
+
2063
+ function removeOwnSession() {
2064
+ var data = readSessionData();
2065
+ if (sessionId in data) {
2066
+ delete data[sessionId];
2067
+ writeSessionData(data);
1897
2068
  }
1898
2069
  }
1899
2070
 
1900
- function updateSessionData() {
1901
- // make sure the current session is added to the list of open sessions
1902
- var sessions = getSessionData();
1903
- if (sessions.find(o => o.session == sessionId)) return;
1904
- var entry = {
1905
- session: sessionId,
1906
- timestamp: Date.now()
1907
- };
1908
- setSessionData(sessions.concat([entry]));
2071
+ function pruneStaleSessionData() {
2072
+ var data = readSessionData();
2073
+ var now = Date.now();
2074
+ var changed = false;
2075
+ Object.keys(data).forEach(function(sid) {
2076
+ if (now - data[sid] > STALE_THRESHOLD_MS) {
2077
+ delete data[sid];
2078
+ changed = true;
2079
+ }
2080
+ });
2081
+ if (changed) writeSessionData(data);
1909
2082
  }
1910
2083
 
1911
- function getSessionData() {
1912
- var data = JSON.parse(window.localStorage.getItem('session_data'));
1913
- return data || [];
2084
+ function readSessionData() {
2085
+ try {
2086
+ var raw = window.localStorage.getItem(SESSION_DATA_KEY);
2087
+ var parsed = raw ? JSON.parse(raw) : null;
2088
+ // Tolerate the legacy array shape by ignoring it (next write replaces it).
2089
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
2090
+ return parsed;
2091
+ }
2092
+ return {};
2093
+ } catch (e) {
2094
+ return {};
2095
+ }
1914
2096
  }
1915
2097
 
1916
- function setSessionData(arr) {
1917
- window.localStorage.setItem('session_data', JSON.stringify(arr));
2098
+ function writeSessionData(obj) {
2099
+ try {
2100
+ window.localStorage.setItem(SESSION_DATA_KEY, JSON.stringify(obj));
2101
+ } catch (e) {} // localStorage can throw on quota exceeded; non-fatal
1918
2102
  }
1919
2103
 
1920
2104
  async function isStorageEnabled() {
1921
2105
  try {
1922
- setSessionData(getSessionData());
2106
+ writeSessionData(readSessionData());
1923
2107
  await updateIndex(function() {});
1924
2108
  return true;
1925
2109
  } catch(e) {
@@ -2399,10 +2583,34 @@
2399
2583
  } else if (await importDataset(group, groupImportOpts)) {
2400
2584
  importCount++;
2401
2585
  gui.session.fileImported(group.filename, optStr);
2586
+ notifyMissingShapefileParts(group);
2402
2587
  }
2403
2588
  }
2404
2589
  }
2405
2590
 
2591
+ // Surface a passive warning if a .shp file came in without its .dbf or .prj
2592
+ // sibling. Both files are technically optional, but their absence has very
2593
+ // different consequences (no attribute data, no projection metadata) and
2594
+ // users frequently load just the .shp by accident. Mapshaper generally
2595
+ // doesn't need .shx, so we don't warn about that.
2596
+ function notifyMissingShapefileParts(group) {
2597
+ if (!group.shp || !gui.notify) return;
2598
+ var missing = [];
2599
+ if (!group.dbf) missing.push({ext: '.dbf', why: 'no attribute data'});
2600
+ if (!group.prj) missing.push({ext: '.prj', why: 'no projection metadata'});
2601
+ if (!missing.length) return;
2602
+ var base = internal.getFileBase(group.shp.filename);
2603
+ var parts = missing.map(function(m) { return m.ext; }).join(' and ');
2604
+ var consequences = missing.map(function(m) { return m.why; }).join(', ');
2605
+ gui.notify({
2606
+ severity: 'warn',
2607
+ body: base + '.shp was loaded without its ' + parts +
2608
+ (missing.length > 1 ? ' files' : ' file') +
2609
+ ' (' + consequences + ').',
2610
+ dedupKey: 'shp-missing:' + base
2611
+ });
2612
+ }
2613
+
2406
2614
  async function importDataset(group, importOpts) {
2407
2615
  var dataset;
2408
2616
  var datasets;
@@ -2943,15 +3151,25 @@
2943
3151
 
2944
3152
  function message() {
2945
3153
  var msg = GUI.formatMessageArgs(arguments);
2946
- gui.message(msg);
2947
- internal.logArgs(arguments);
3154
+ if (gui.notify) {
3155
+ gui.notify({severity: 'info', body: msg});
3156
+ } else {
3157
+ // Fallback for early messages before MessageControl is constructed
3158
+ gui.message(msg);
3159
+ internal.logArgs(arguments);
3160
+ }
2948
3161
  }
2949
3162
 
2950
- // GUI warning uses the alert popup, which replaces previous popup
2951
- // (unlike message) -- this allows for catching and handling errors
2952
- // by replacing the error popup with a warning.
3163
+ // CLI warnings used to surface as modal alerts, which interrupt the user
3164
+ // and replace any previous popup. They now go to the Messages inbox so
3165
+ // they can accumulate non-disruptively.
2953
3166
  function warn() {
2954
- gui.alert(GUI.formatMessageArgs(arguments));
3167
+ var msg = GUI.formatMessageArgs(arguments);
3168
+ if (gui.notify) {
3169
+ gui.notify({severity: 'warn', body: msg, dedupKey: 'warn:' + msg});
3170
+ } else {
3171
+ gui.alert(msg);
3172
+ }
2955
3173
  }
2956
3174
 
2957
3175
  internal.setLoggingFunctions(message, error, stop, warn);
@@ -2970,7 +3188,11 @@
2970
3188
  try {
2971
3189
  await utils$1.promisify(saveFilesToServer)(paths, data);
2972
3190
  if (files.length >= 1) {
2973
- gui.alert('<b>Saved</b><br>' + paths.join('<br>'));
3191
+ if (gui.notify) {
3192
+ gui.notify({severity: 'info', title: 'Saved', body: paths.join('\n')});
3193
+ } else {
3194
+ gui.alert('<b>Saved</b><br>' + paths.join('<br>'));
3195
+ }
2974
3196
  }
2975
3197
  } catch(err) {
2976
3198
  msg = "<b>Direct save failed</b><br>Reason: " + err.message + ".";
@@ -14876,6 +15098,200 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
14876
15098
  };
14877
15099
  }
14878
15100
 
15101
+ // Passive "Messages" inbox: a non-blocking alternative to modal alerts for
15102
+ // status messages and warnings. Backed by a header button (the envelope icon)
15103
+ // in #mode-buttons and a popup-dialog panel.
15104
+ //
15105
+ // Public API (attached to the gui instance):
15106
+ // gui.notify({severity, body, title?, dedupKey?}) add an entry
15107
+ // gui.notify(message) shorthand: info severity
15108
+ //
15109
+ // severity is one of 'info' | 'warn' | 'error' (default 'info').
15110
+ // If dedupKey is given and an entry with the same key already exists, the
15111
+ // existing entry's count is incremented and its timestamp updated instead of
15112
+ // adding a new row -- handy for repeated CLI warnings.
15113
+
15114
+
15115
+ var SEVERITIES = {info: true, warn: true, error: true};
15116
+ var PULSE_DURATION_MS = 1900; // matches the CSS animation total runtime
15117
+
15118
+ function MessageControl(gui) {
15119
+ var btn = gui.container.findChild('.messages-btn');
15120
+ var badge = btn.findChild('.messages-badge');
15121
+ var panel = gui.container.findChild('.messages-panel');
15122
+ var listEl = panel.findChild('.messages-list');
15123
+ var emptyEl = panel.findChild('.messages-empty');
15124
+ var clearBtn = new SimpleButton(panel.findChild('.messages-clear-btn'));
15125
+ var closeBtn = new SimpleButton(panel.findChild('.close2-btn'));
15126
+ var entries = [];
15127
+ var nextId = 1;
15128
+ var pulseTimer = null;
15129
+ var rendered = false;
15130
+
15131
+ // Start fully hidden until the first notification arrives, so the icon
15132
+ // doesn't take up header space when there's nothing to show.
15133
+ btn.addClass('hidden');
15134
+ gui.addMode('messages', turnOn, turnOff, btn);
15135
+
15136
+ closeBtn.on('click', function() {
15137
+ gui.clearMode();
15138
+ });
15139
+
15140
+ clearBtn.on('click', function() {
15141
+ if (entries.length === 0) return;
15142
+ entries = [];
15143
+ // Close the panel so the now-disabled envelope isn't holding open a
15144
+ // panel the user can no longer dismiss with a header click.
15145
+ gui.clearMode();
15146
+ renderList();
15147
+ updateBadge();
15148
+ });
15149
+
15150
+ // Public API
15151
+ gui.notify = function(opts) {
15152
+ if (typeof opts == 'string') {
15153
+ opts = {body: opts};
15154
+ }
15155
+ opts = opts || {};
15156
+ var severity = SEVERITIES[opts.severity] ? opts.severity : 'info';
15157
+ var body = opts.body == null ? '' : String(opts.body);
15158
+ var title = opts.title || null;
15159
+ if (!body && !title) return;
15160
+
15161
+ if (opts.dedupKey) {
15162
+ for (var i = 0; i < entries.length; i++) {
15163
+ if (entries[i].dedupKey === opts.dedupKey) {
15164
+ entries[i].count = (entries[i].count || 1) + 1;
15165
+ entries[i].time = new Date();
15166
+ // Promote severity if a later occurrence is more severe.
15167
+ if (severityRank(severity) > severityRank(entries[i].severity)) {
15168
+ entries[i].severity = severity;
15169
+ }
15170
+ renderList();
15171
+ pulseBadge();
15172
+ return;
15173
+ }
15174
+ }
15175
+ }
15176
+
15177
+ entries.unshift({
15178
+ id: nextId++,
15179
+ severity: severity,
15180
+ title: title,
15181
+ body: body,
15182
+ count: 1,
15183
+ time: new Date(),
15184
+ dedupKey: opts.dedupKey || null
15185
+ });
15186
+ renderList();
15187
+ updateBadge();
15188
+ pulseBadge();
15189
+
15190
+ // Mirror to the JS console so power users still see a record even if they
15191
+ // never open the panel. Use the matching console method per severity.
15192
+ var rec = (title ? title + ': ' : '') + body;
15193
+ if (severity === 'error') console.error(rec);
15194
+ else if (severity === 'warn') console.warn(rec);
15195
+ else if (typeof internal !== 'undefined' && internal.logArgs) {
15196
+ internal.logArgs([rec]);
15197
+ } else {
15198
+ console.log(rec);
15199
+ }
15200
+ };
15201
+
15202
+ function severityRank(s) {
15203
+ return s === 'error' ? 2 : s === 'warn' ? 1 : 0;
15204
+ }
15205
+
15206
+ function turnOn() {
15207
+ if (!rendered) renderList();
15208
+ panel.show();
15209
+ }
15210
+
15211
+ function turnOff() {
15212
+ panel.hide();
15213
+ }
15214
+
15215
+ function updateBadge() {
15216
+ var n = entries.length;
15217
+ var hasWarn = false;
15218
+ var hasError = false;
15219
+ for (var i = 0; i < entries.length; i++) {
15220
+ if (entries[i].severity === 'error') hasError = true;
15221
+ else if (entries[i].severity === 'warn') hasWarn = true;
15222
+ }
15223
+ badge.text(n > 99 ? '99+' : String(n));
15224
+ badge.classed('hidden', n === 0);
15225
+ badge.classed('warn', hasWarn && !hasError);
15226
+ badge.classed('error', hasError);
15227
+ btn.classed('hidden', n === 0);
15228
+ }
15229
+
15230
+ function pulseBadge() {
15231
+ btn.removeClass('pulse');
15232
+ // Force reflow so removing+adding the class restarts the CSS animation.
15233
+ void btn.node().offsetWidth;
15234
+ btn.addClass('pulse');
15235
+ if (pulseTimer) clearTimeout(pulseTimer);
15236
+ pulseTimer = setTimeout(function() {
15237
+ btn.removeClass('pulse');
15238
+ pulseTimer = null;
15239
+ }, PULSE_DURATION_MS);
15240
+ }
15241
+
15242
+ function renderList() {
15243
+ rendered = true;
15244
+ listEl.empty();
15245
+ if (entries.length === 0) {
15246
+ emptyEl.show();
15247
+ return;
15248
+ }
15249
+ emptyEl.hide();
15250
+ for (var i = 0; i < entries.length; i++) {
15251
+ listEl.node().appendChild(renderItem(entries[i]).node());
15252
+ }
15253
+ }
15254
+
15255
+ function renderItem(entry) {
15256
+ var item = El('div').addClass('message-item').addClass('severity-' + entry.severity);
15257
+ if (entry.title) {
15258
+ El('span').addClass('message-title').text(entry.title).appendTo(item);
15259
+ }
15260
+ var bodyText = entry.body || '';
15261
+ if (entry.count > 1) {
15262
+ bodyText += ' (\u00d7' + entry.count + ')';
15263
+ }
15264
+ El('div').addClass('message-body').text(bodyText).appendTo(item);
15265
+ El('span').addClass('message-time').text(formatTime(entry.time)).appendTo(item);
15266
+ var dismiss = El('span').addClass('message-dismiss').attr('title', 'Dismiss').text('\u00d7').appendTo(item);
15267
+ var entryId = entry.id;
15268
+ dismiss.on('click', function(e) {
15269
+ // Prevent the click from also triggering any panel-level handlers.
15270
+ if (e && e.stopPropagation) e.stopPropagation();
15271
+ entries = entries.filter(function(x) { return x.id !== entryId; });
15272
+ // If we just dismissed the last entry, close the panel for the same
15273
+ // reason as Clear all: the envelope will go disabled and the user
15274
+ // wouldn't be able to dismiss the panel by clicking the header again.
15275
+ if (entries.length === 0) gui.clearMode();
15276
+ renderList();
15277
+ updateBadge();
15278
+ });
15279
+ return item;
15280
+ }
15281
+
15282
+ function formatTime(d) {
15283
+ if (!(d instanceof Date)) return '';
15284
+ var hh = String(d.getHours()).padStart(2, '0');
15285
+ var mm = String(d.getMinutes()).padStart(2, '0');
15286
+ return hh + ':' + mm;
15287
+ }
15288
+
15289
+ return {
15290
+ notify: gui.notify,
15291
+ count: function() { return entries.length; }
15292
+ };
15293
+ }
15294
+
14879
15295
  // import { ProjectOptions } from './gui-project-control';
14880
15296
 
14881
15297
 
@@ -14897,6 +15313,7 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
14897
15313
  gui.keyboard = new KeyboardEvents(gui);
14898
15314
  gui.buttons = new SidebarButtons(gui);
14899
15315
  gui.display = new DisplayOptions(gui);
15316
+ gui.messages = new MessageControl(gui);
14900
15317
  gui.basemap = new Basemap(gui);
14901
15318
  gui.session = new SessionHistory(gui);
14902
15319
  gui.contextMenu = new ContextMenu();
@@ -14988,6 +15405,25 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
14988
15405
  // This is the entry point for bundling mapshaper's web UI
14989
15406
 
14990
15407
 
15408
+ // Refresh detection for mapshaper-gui: if the previous incarnation of this
15409
+ // tab set the 'navigating away' marker on pagehide, then this page load is a
15410
+ // reload (not a fresh tab). Tell the server to cancel any pending /close,
15411
+ // otherwise the server's grace window can race past while the new page is
15412
+ // idle and terminate the process. sessionStorage is per-tab and survives a
15413
+ // refresh, but is gone after a real close -- exactly the signal we need.
15414
+ // This runs before onload to fire as early as possible in the page lifecycle.
15415
+ if (typeof window !== 'undefined' &&
15416
+ window.location.hostname === 'localhost' &&
15417
+ window.sessionStorage &&
15418
+ window.sessionStorage.getItem('mapshaper_navigating_away')) {
15419
+ window.sessionStorage.removeItem('mapshaper_navigating_away');
15420
+ if (navigator.sendBeacon) {
15421
+ navigator.sendBeacon('/cancel-close');
15422
+ } else {
15423
+ try { fetch('/cancel-close', {method: 'GET', keepalive: true}); } catch (err) {}
15424
+ }
15425
+ }
15426
+
14991
15427
  onload(function() {
14992
15428
  if (!GUI.browserIsSupported()) {
14993
15429
  El("#mshp-not-supported").show();
@@ -15031,10 +15467,9 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
15031
15467
  importOpts = getImportOpts(manifest),
15032
15468
  gui = new GuiInstance('body');
15033
15469
 
15034
- // TODO: re-enable the "blurb"
15035
- // if (manifest.blurb) {
15036
- // El('#splash-screen-blurb').text(manifest.blurb);
15037
- // }
15470
+ if (!importOpts.files?.length) {
15471
+ El('body').removeClass('mapshaper-preload');
15472
+ }
15038
15473
 
15039
15474
  new AlertControl(gui);
15040
15475
  new IntersectionControl(gui);
@@ -15056,12 +15491,23 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
15056
15491
  }
15057
15492
  });
15058
15493
 
15059
- window.addEventListener('unload', function(e) {
15060
- if (window.location.hostname == 'localhost') {
15061
- // send termination signal for mapshaper-gui
15062
- var req = new XMLHttpRequest();
15063
- req.open('GET', '/close');
15064
- req.send();
15494
+ // Send termination signal to mapshaper-gui when the tab is closed.
15495
+ // Use 'pagehide' rather than 'unload' (the latter is increasingly suppressed
15496
+ // by Chrome/Edge for bfcache reasons), and use sendBeacon / keepalive fetch
15497
+ // because async XHR in the unload path is not guaranteed to be sent.
15498
+ // The sessionStorage marker is the refresh-vs-close signal: if a new page
15499
+ // loads in the same tab, it'll see the marker and send /cancel-close to
15500
+ // abort the pending exit (see the top of this file).
15501
+ window.addEventListener('pagehide', function(e) {
15502
+ if (window.location.hostname != 'localhost') return;
15503
+ if (e.persisted) return; // page is being cached, not actually closed
15504
+ try { window.sessionStorage.setItem('mapshaper_navigating_away', '1'); } catch (err) {}
15505
+ if (navigator.sendBeacon) {
15506
+ navigator.sendBeacon('/close');
15507
+ } else {
15508
+ try {
15509
+ fetch('/close', {method: 'GET', keepalive: true});
15510
+ } catch (err) {}
15065
15511
  }
15066
15512
  });
15067
15513