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.
- package/LICENSE +1 -1
- package/bin/mapshaper-gui +31 -7
- package/mapshaper.js +45 -42
- package/package.json +8 -7
- package/www/geopackage.js +10 -10
- package/www/index.html +32 -4
- package/www/mapshaper-gui.js +529 -83
- package/www/mapshaper.js +45 -42
- package/www/modules.js +5 -5
- package/www/page.css +157 -0
- package/www/docs/_assets/cmd-search.js +0 -213
- package/www/docs/_assets/docs.css +0 -722
- package/www/docs/_assets/docs.js +0 -75
- package/www/docs/_assets/highlight.css +0 -10
- package/www/docs/essentials/command-line.html +0 -127
- package/www/docs/essentials/command-line.html.md +0 -112
- package/www/docs/essentials/web-app.html +0 -138
- package/www/docs/essentials/web-app.html.md +0 -106
- package/www/docs/examples/basics.html +0 -275
- package/www/docs/examples/basics.html.md +0 -370
- package/www/docs/examples/data/Makefile +0 -31
- package/www/docs/examples/data/globe.msx +0 -0
- package/www/docs/examples/data/globe.svg +0 -616
- package/www/docs/examples/data/globe.txt +0 -21
- package/www/docs/examples/data/globe.zip +0 -0
- package/www/docs/examples/data/ne_50m_admin_0_countries.geojson +0 -1
- package/www/docs/examples/data/ne_50m_admin_1_states_provinces_lakes.geojson +0 -1
- package/www/docs/examples/data/us-states.msx +0 -0
- package/www/docs/examples/data/us-states.svg +0 -56
- package/www/docs/examples/data/us-states.txt +0 -6
- package/www/docs/examples/data/us-states.zip +0 -0
- package/www/docs/examples/globe.html +0 -108
- package/www/docs/examples/globe.html.md +0 -64
- package/www/docs/examples/us-states.html +0 -88
- package/www/docs/examples/us-states.html.md +0 -44
- package/www/docs/formats/csv.html +0 -127
- package/www/docs/formats/csv.html.md +0 -97
- package/www/docs/formats/dbf.html +0 -87
- package/www/docs/formats/dbf.html.md +0 -39
- package/www/docs/formats/flatgeobuf.html +0 -85
- package/www/docs/formats/flatgeobuf.html.md +0 -41
- package/www/docs/formats/geojson.html +0 -107
- package/www/docs/formats/geojson.html.md +0 -65
- package/www/docs/formats/geopackage.html +0 -87
- package/www/docs/formats/geopackage.html.md +0 -42
- package/www/docs/formats/json.html +0 -83
- package/www/docs/formats/json.html.md +0 -35
- package/www/docs/formats/kml.html +0 -82
- package/www/docs/formats/kml.html.md +0 -39
- package/www/docs/formats/overview.html +0 -191
- package/www/docs/formats/overview.html.md +0 -34
- package/www/docs/formats/shapefile.html +0 -123
- package/www/docs/formats/shapefile.html.md +0 -84
- package/www/docs/formats/snapshot.html +0 -87
- package/www/docs/formats/snapshot.html.md +0 -39
- package/www/docs/formats/svg.html +0 -99
- package/www/docs/formats/svg.html.md +0 -51
- package/www/docs/formats/topojson.html +0 -102
- package/www/docs/formats/topojson.html.md +0 -54
- package/www/docs/gallery/index.html +0 -80
- package/www/docs/gallery/index.html.md +0 -29
- package/www/docs/guides/combining-layers.html +0 -109
- package/www/docs/guides/combining-layers.html.md +0 -89
- package/www/docs/guides/expressions.html +0 -600
- package/www/docs/guides/expressions.html.md +0 -376
- package/www/docs/guides/programmatic.html +0 -117
- package/www/docs/guides/programmatic.html.md +0 -91
- package/www/docs/guides/projections.html +0 -158
- package/www/docs/guides/projections.html.md +0 -118
- package/www/docs/guides/simplification.html +0 -110
- package/www/docs/guides/simplification.html.md +0 -96
- package/www/docs/guides/topology.html +0 -90
- package/www/docs/guides/topology.html.md +0 -63
- package/www/docs/images/cal-counties.png +0 -0
- package/www/docs/images/simplification-detail.png +0 -0
- package/www/docs/images/simplification-dp.png +0 -0
- package/www/docs/images/simplification-mod2.png +0 -0
- package/www/docs/images/tiger-counties.png +0 -0
- package/www/docs/index.html +0 -101
- package/www/docs/index.html.md +0 -59
- package/www/docs/reference.html +0 -1302
- package/www/docs/reference.html.md +0 -1817
- package/www/docs/whats-new.html +0 -77
- package/www/docs/whats-new.html.md +0 -59
- package/www/llms-full.txt +0 -4053
- package/www/llms.txt +0 -55
package/www/mapshaper-gui.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
|
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
|
-
|
|
1835
|
+
touchOwnSession();
|
|
1800
1836
|
return updateIndex(function(index) {
|
|
1801
1837
|
index.snapshots.push(obj);
|
|
1802
1838
|
});
|
|
1803
1839
|
}
|
|
1804
1840
|
|
|
1805
|
-
|
|
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
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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
|
|
1866
|
+
var doomedKeys = [];
|
|
1867
|
+
var doomedSessions = new Set();
|
|
1822
1868
|
keys.forEach(function(key) {
|
|
1823
|
-
|
|
1824
|
-
|
|
1869
|
+
var sid = getSessionFromSnapshotId(key);
|
|
1870
|
+
if (sid && !liveSessions.has(sid)) {
|
|
1871
|
+
doomedKeys.push(key);
|
|
1872
|
+
doomedSessions.add(sid);
|
|
1825
1873
|
}
|
|
1826
1874
|
});
|
|
1827
|
-
|
|
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
|
-
|
|
1831
|
-
var
|
|
1832
|
-
|
|
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
|
-
//
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
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
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
|
1901
|
-
|
|
1902
|
-
var
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
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
|
|
1912
|
-
|
|
1913
|
-
|
|
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
|
|
1917
|
-
|
|
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
|
-
|
|
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.
|
|
2947
|
-
|
|
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
|
-
//
|
|
2951
|
-
//
|
|
2952
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
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
|
-
|
|
15060
|
-
|
|
15061
|
-
|
|
15062
|
-
|
|
15063
|
-
|
|
15064
|
-
|
|
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
|
|