cache derived geometry: merged road chains + settlement list persisted with cell fingerprints; two-tier best-first road stitching (raw-node headings, permissive interchange pass); fast path skips all raw parsing on unchanged data
This commit is contained in:
1 parent
99b29f216a
commit
aebe311e32
2 files changed
+370
-29
No files matched your search
@@ -30,8 +30,7 @@ real terrain, driven by the real sun, moon, stars, and live weather.
|
|||||||
mood (sunny / cloudy / stormy / night).
|
mood (sunny / cloudy / stormy / night).
|
||||||
- **Fully cached, offline-capable**: basemap tiles, elevation grid,
|
- **Fully cached, offline-capable**: basemap tiles, elevation grid,
|
||||||
Overpass road/place data, star catalogue and the last weather payload
|
Overpass road/place data, star catalogue and the last weather payload
|
||||||
live permanently in IndexedDB. Once warm, the scene renders fully
|
live permanently in IndexedDB. Once warm, the scene renders fully offline, and a 15-minute cycle transparently retries anything that previously failed. Merged road chains and the decoded settlement list are cached as well and only recomputed when an Overpass cell is actually re-fetched.tiles.expireDays`) can refresh
|
||||||
offline; a configurable expiry (`CONFIG.tiles.expireDays`) can refresh
|
|
||||||
basemap tiles periodically.
|
basemap tiles periodically.
|
||||||
- **Location-aware**: set `CONFIG.location.lat/lon`; everything
|
- **Location-aware**: set `CONFIG.location.lat/lon`; everything
|
||||||
(flight anchor, terrain tiles, sun/moon, weather, star rotation) is
|
(flight anchor, terrain tiles, sun/moon, weather, star rotation) is
|
||||||
|
|||||||
Vendored
+369
-27
@@ -1977,13 +1977,21 @@ function resamplePoly(pts) {
|
|||||||
leftover += seg;
|
leftover += seg;
|
||||||
prev = p;
|
prev = p;
|
||||||
}
|
}
|
||||||
|
// keep the ORIGINAL end point: OSM ways of one road share exact
|
||||||
|
// junction coordinates, and carMergeChains matches those seams
|
||||||
|
var end = pts[pts.length - 1];
|
||||||
|
var last = out[out.length - 1];
|
||||||
|
if (Math.hypot(end.x - last.x, end.z - last.z) > 1e-9) {
|
||||||
|
out.push({ x: end.x, z: end.z, d: last.d + leftover });
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchMotorways() {
|
function fetchMotorways() {
|
||||||
if (!CAR_ON || carFetching) return;
|
if (!CAR_ON || carFetching) return;
|
||||||
carFetching = true;
|
carFetching = true;
|
||||||
var fresh = 0; // cells that were NOT already cached this cycle
|
var fresh = 0; // cells that needed a real fetch this cycle
|
||||||
|
var fpParts = []; // "id:savedAt" of every raw cell -> fp()
|
||||||
var groups = { motorway: [], secondary: [] };
|
var groups = { motorway: [], secondary: [] };
|
||||||
function parseWays(txt) {
|
function parseWays(txt) {
|
||||||
var data;
|
var data;
|
||||||
@@ -1998,7 +2006,35 @@ function fetchMotorways() {
|
|||||||
return { x: carLonToX(g.lon), z: carLatToZ(g.lat) };
|
return { x: carLonToX(g.lon), z: carLatToZ(g.lat) };
|
||||||
});
|
});
|
||||||
var rp = resamplePoly(pts);
|
var rp = resamplePoly(pts);
|
||||||
if (rp.length > 1) groups[key].push(rp);
|
if (rp.length > 1) {
|
||||||
|
// local heading at both ends from RAW consecutive nodes
|
||||||
|
// (resampled 2 km chords cut curves too aggressively for
|
||||||
|
// seam-direction matching on real motorway curves)
|
||||||
|
var h0 = pts[1], h1 = pts[0];
|
||||||
|
var t0 = pts[pts.length - 1], t1 = pts[pts.length - 2];
|
||||||
|
var hl = Math.hypot(h0.x - h1.x, h0.z - h1.z) || 1;
|
||||||
|
var tl = Math.hypot(t0.x - t1.x, t0.z - t1.z) || 1;
|
||||||
|
rp.headD = { x: (h1.x - h0.x) / hl, z: (h1.z - h0.z) / hl }; // away from body
|
||||||
|
rp.tailD = { x: (t0.x - t1.x) / tl, z: (t0.z - t1.z) / tl };
|
||||||
|
groups[key].push(rp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// stitch the raw ways into continuous chains, rebuild ribbons and
|
||||||
|
// (optionally on cycle completion) persist the chains for fast loads
|
||||||
|
function rebuildChains(persist) {
|
||||||
|
var mCh = carMergeChains(groups.motorway);
|
||||||
|
var bCh = carMergeChains(groups.secondary);
|
||||||
|
carRawCounts = [groups.motorway.length, groups.secondary.length];
|
||||||
|
carGeo = { motorway: mCh, secondary: bCh };
|
||||||
|
buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
||||||
|
if (!persist) return;
|
||||||
|
var em = encodeChains(mCh), eb = encodeChains(bCh);
|
||||||
|
openTileDB().then(function (db) {
|
||||||
|
if (!db) return;
|
||||||
|
tileDbPut(db, { id: 'geo/roadchains_v1', fp: cellsFp(fpParts),
|
||||||
|
mCnts: em.cnts, mData: em.data,
|
||||||
|
bCnts: eb.cnts, bData: eb.data, savedAt: Date.now() });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// rebuild ribbons shortly after each arriving cell - the lights grow
|
// rebuild ribbons shortly after each arriving cell - the lights grow
|
||||||
@@ -2008,8 +2044,7 @@ function fetchMotorways() {
|
|||||||
if (rebuildT) return;
|
if (rebuildT) return;
|
||||||
rebuildT = setTimeout(function () {
|
rebuildT = setTimeout(function () {
|
||||||
rebuildT = null;
|
rebuildT = null;
|
||||||
carGeo = groups;
|
rebuildChains(false);
|
||||||
buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
function loadCell(entry) {
|
function loadCell(entry) {
|
||||||
@@ -2017,36 +2052,65 @@ function fetchMotorways() {
|
|||||||
return tileDbGet(db, entry.id).then(function (rec) {
|
return tileDbGet(db, entry.id).then(function (rec) {
|
||||||
// validate stored JSON - a poisoned cache record (HTML error
|
// validate stored JSON - a poisoned cache record (HTML error
|
||||||
// page saved by an older build) must trigger a refetch
|
// page saved by an older build) must trigger a refetch
|
||||||
if (rec && rec.json && isValidOverpass(rec.json)) { entry._cached = true; return rec.json; }
|
if (rec && rec.json && isValidOverpass(rec.json)) {
|
||||||
|
entry._cached = true;
|
||||||
|
fpParts.push(entry.id + ':' + rec.savedAt);
|
||||||
|
return rec.json;
|
||||||
|
}
|
||||||
return opTry(entry.q, entry.start).then(function (txt) {
|
return opTry(entry.q, entry.start).then(function (txt) {
|
||||||
if (txt && db) tileDbPut(db, { id: entry.id, json: txt, savedAt: Date.now() });
|
if (txt && db) {
|
||||||
|
var ts = Date.now();
|
||||||
|
fpParts.push(entry.id + ':' + ts);
|
||||||
|
tileDbPut(db, { id: entry.id, json: txt, savedAt: ts });
|
||||||
|
}
|
||||||
return txt;
|
return txt;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}).then(function (txt) {
|
}).then(function (txt) {
|
||||||
if (txt) {
|
if (txt) {
|
||||||
parseWays(txt);
|
parseWays(txt);
|
||||||
// cached cells are re-parsed but never trigger a rebuild;
|
if (!entry._cached) fresh++;
|
||||||
// only freshly fetched data changes the ribbons
|
if (!entry._cached || !carGeo) scheduleRebuild();
|
||||||
if (!entry._cached) { fresh++; scheduleRebuild(); }
|
|
||||||
}
|
}
|
||||||
else console.warn('[clouds3d] overpass cell failed: ' + entry.id);
|
else console.warn('[clouds3d] overpass cell failed: ' + entry.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// cached cells resolve instantly; fresh ones are paced by the global
|
// Fast path: merged chains are cached AND the fingerprint over all raw
|
||||||
// gate. Failed cells are retried on the next 15-min cycle
|
// cell records is unchanged -> restore chains straight to the GPU.
|
||||||
// (successful ones stay cached forever).
|
// Fingerprint check reads record metadata only, no JSON parsing.
|
||||||
|
openTileDB().then(function (db) {
|
||||||
|
if (!db) return false;
|
||||||
|
return tileDbGet(db, 'geo/roadchains_v1').then(function (rec) {
|
||||||
|
if (!rec || !rec.mCnts || !rec.fp) return false;
|
||||||
|
var ok = true, parts = [];
|
||||||
|
return runPool(CAR_QUERIES, 8, function (e) {
|
||||||
|
if (!ok) return Promise.resolve();
|
||||||
|
return tileDbGet(db, e.id).then(function (r) {
|
||||||
|
if (!r || !isValidOverpass(r.json)) ok = false;
|
||||||
|
else parts.push(e.id + ':' + r.savedAt);
|
||||||
|
});
|
||||||
|
}).then(function () {
|
||||||
|
if (!ok || cellsFp(parts) !== rec.fp) return false;
|
||||||
|
carGeo = { motorway: decodeChains(rec.mCnts, rec.mData),
|
||||||
|
secondary: decodeChains(rec.bCnts, rec.bData) };
|
||||||
|
buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
||||||
|
console.log('[clouds3d] car layer: ' + carGeo.motorway.length +
|
||||||
|
' motorway + ' + carGeo.secondary.length + ' chains (from cache)');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).then(function (hit) {
|
||||||
|
if (hit) { carFetching = false; return; }
|
||||||
|
// slow path: fetch what is missing, then stitch + persist once
|
||||||
runPool(CAR_QUERIES, 2, function (entry) {
|
runPool(CAR_QUERIES, 2, function (entry) {
|
||||||
return loadCell(entry);
|
return loadCell(entry);
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
carFetching = false;
|
carFetching = false;
|
||||||
// a fully cached cycle with no prior failure means the ribbons
|
rebuildChains(true);
|
||||||
// are already in the state this cycle would rebuild - skip it
|
|
||||||
if (!fresh && carGeo) return;
|
|
||||||
carGeo = groups;
|
|
||||||
buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
|
||||||
console.log('[clouds3d] car layer: ' + groups.motorway.length +
|
console.log('[clouds3d] car layer: ' + groups.motorway.length +
|
||||||
' motorway + ' + groups.secondary.length + ' secondary polylines');
|
' motorway + ' + groups.secondary.length + ' secondary polylines -> longest chain ~' +
|
||||||
|
Math.round(carMaxChainKm) + ' km');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2074,6 +2138,7 @@ function fetchSettlements() {
|
|||||||
if (!CITY_ON || cityFetching) return;
|
if (!CITY_ON || cityFetching) return;
|
||||||
cityFetching = true;
|
cityFetching = true;
|
||||||
var fresh = 0;
|
var fresh = 0;
|
||||||
|
var fpParts = [];
|
||||||
// fine 0.55 x 0.65 deg cells: every query answers in <20 s, which is
|
// fine 0.55 x 0.65 deg cells: every query answers in <20 s, which is
|
||||||
// the only reliable regime on the public mirrors (big boxes server-
|
// the only reliable regime on the public mirrors (big boxes server-
|
||||||
// time out at any timeout: value and come back as unparseable junk).
|
// time out at any timeout: value and come back as unparseable junk).
|
||||||
@@ -2140,32 +2205,66 @@ function fetchSettlements() {
|
|||||||
function loadCell(entry) {
|
function loadCell(entry) {
|
||||||
return openTileDB().then(function (db) {
|
return openTileDB().then(function (db) {
|
||||||
return tileDbGet(db, entry.id).then(function (rec) {
|
return tileDbGet(db, entry.id).then(function (rec) {
|
||||||
// validate stored JSON - a poisoned cache record (HTML error
|
if (rec && rec.json && isValidOverpass(rec.json)) {
|
||||||
// page saved by an older build) must trigger a refetch
|
entry._cached = true;
|
||||||
if (rec && rec.json && isValidOverpass(rec.json)) { entry._cached = true; return rec.json; }
|
fpParts.push(entry.id + ':' + rec.savedAt);
|
||||||
|
return rec.json;
|
||||||
|
}
|
||||||
return opTry(entry.q, entry.start).then(function (txt) {
|
return opTry(entry.q, entry.start).then(function (txt) {
|
||||||
if (txt && db) tileDbPut(db, { id: entry.id, json: txt, savedAt: Date.now() });
|
if (txt && db) {
|
||||||
|
var ts = Date.now();
|
||||||
|
fpParts.push(entry.id + ':' + ts);
|
||||||
|
tileDbPut(db, { id: entry.id, json: txt, savedAt: ts });
|
||||||
|
}
|
||||||
return txt;
|
return txt;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}).then(function (txt) {
|
}).then(function (txt) {
|
||||||
if (txt) {
|
if (txt) {
|
||||||
parse(txt);
|
parse(txt);
|
||||||
if (!entry._cached) { fresh++; scheduleRebuild(); }
|
if (!entry._cached) fresh++;
|
||||||
|
if (!entry._cached || !cityGeo) scheduleRebuild();
|
||||||
}
|
}
|
||||||
else console.warn('[clouds3d] place cell failed: ' + entry.id);
|
else console.warn('[clouds3d] place cell failed: ' + entry.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// fast path: decoded settlement list cached with a matching cell fp
|
||||||
|
openTileDB().then(function (db) {
|
||||||
|
if (!db) return false;
|
||||||
|
return tileDbGet(db, 'geo/placecache_v1').then(function (rec) {
|
||||||
|
if (!rec || !rec.cnt || !rec.fp) return false;
|
||||||
|
var ok = true, parts = [];
|
||||||
|
return runPool(cells, 8, function (e) {
|
||||||
|
if (!ok) return Promise.resolve();
|
||||||
|
return tileDbGet(db, e.id).then(function (r) {
|
||||||
|
if (!r || !isValidOverpass(r.json)) ok = false;
|
||||||
|
else parts.push(e.id + ':' + r.savedAt);
|
||||||
|
});
|
||||||
|
}).then(function () {
|
||||||
|
if (!ok || cellsFp(parts) !== rec.fp) return false;
|
||||||
|
cityGeo = decodePlaces(rec.cnt, rec.data);
|
||||||
|
buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
||||||
|
console.log('[clouds3d] settlements: ' + cityGeo.length + ' places (from cache)');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).then(function (hit) {
|
||||||
|
if (hit) { cityFetching = false; return; }
|
||||||
runPool(cells, 2, function (entry) {
|
runPool(cells, 2, function (entry) {
|
||||||
return loadCell(entry);
|
return loadCell(entry);
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
cityFetching = false;
|
cityFetching = false;
|
||||||
if (!fresh && cityGeo) return; // fully cached cycle - nothing changed
|
|
||||||
cityGeo = list.slice();
|
cityGeo = list.slice();
|
||||||
buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
||||||
|
openTileDB().then(function (db) {
|
||||||
|
if (!db) return;
|
||||||
|
tileDbPut(db, { id: 'geo/placecache_v1', fp: cellsFp(fpParts),
|
||||||
|
cnt: list.length, data: encodePlaces(list), savedAt: Date.now() });
|
||||||
|
});
|
||||||
console.log('[clouds3d] settlements: ' + list.length + ' places -> ' + cityCount + ' lights' +
|
console.log('[clouds3d] settlements: ' + list.length + ' places -> ' + cityCount + ' lights' +
|
||||||
(cityCount ? '' : ' (terrain pending)'));
|
(cityCount ? '' : ' (terrain pending)'));
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var CAR_VERT_SRC = [
|
var CAR_VERT_SRC = [
|
||||||
@@ -2226,6 +2325,10 @@ var CAR_FRAG_SRC = [
|
|||||||
// cars are slotted pulses; hash1(s) jitters each slot's position
|
// cars are slotted pulses; hash1(s) jitters each slot's position
|
||||||
// (varying spacing) and a per-slot sine wobble makes every vehicle
|
// (varying spacing) and a per-slot sine wobble makes every vehicle
|
||||||
// crawl or overtake a bit (varying speeds). No trails - points only.
|
// crawl or overtake a bit (varying speeds). No trails - points only.
|
||||||
|
// Each slot also has a per-vehicle LIFE CYCLE: it switches on with
|
||||||
|
// a 5 s fade-in, drives for a random 1-20 min, fades out and rests
|
||||||
|
// for a random ~0.5-30 min before its next trip (on/off ramps in
|
||||||
|
// spirit) - the traffic field never sits on a fixed pattern.
|
||||||
'float flow = vLen / uDash + dir * uTime * uSpeed;',
|
'float flow = vLen / uDash + dir * uTime * uSpeed;',
|
||||||
// pulse width grows with distance (~1.5 px floor at the fade edge)
|
// pulse width grows with distance (~1.5 px floor at the fade edge)
|
||||||
// so distant cars read as slightly larger, steady lights instead
|
// so distant cars read as slightly larger, steady lights instead
|
||||||
@@ -2237,10 +2340,17 @@ var CAR_FRAG_SRC = [
|
|||||||
'float s = n + float(k);',
|
'float s = n + float(k);',
|
||||||
'float vis = step(hash1(s + 17.3), dens);',
|
'float vis = step(hash1(s + 17.3), dens);',
|
||||||
'if (vis > 0.0) {',
|
'if (vis > 0.0) {',
|
||||||
|
'float life = 60.0 + 1140.0 * hash1(s * 0.371 + 9.4);',
|
||||||
|
'float rest = 30.0 + 1770.0 * hash1(s * 0.617 + 3.2);',
|
||||||
|
'float per = life + rest;',
|
||||||
|
'float age = mod(uTime + 3607.0 * hash1(s * 0.913 + 7.7), per);',
|
||||||
|
'float on = smoothstep(0.0, 5.0, age) * (1.0 - smoothstep(life - 5.0, life, age));',
|
||||||
|
'if (on > 0.001) {',
|
||||||
'float off = 0.05 + hash1(s) * 0.8;',
|
'float off = 0.05 + hash1(s) * 0.8;',
|
||||||
'float w = 0.25 * sin(uTime * uSpeed * (1.5 + hash1(s + 7.1) * 4.0) + hash1(s + 3.7) * 6.283);',
|
'float w = 0.25 * sin(uTime * uSpeed * (1.5 + hash1(s + 7.1) * 4.0) + hash1(s + 3.7) * 6.283);',
|
||||||
'float d = abs(flow - (s + off + w));',
|
'float d = abs(flow - (s + off + w));',
|
||||||
'inten = max(inten, max(0.0, 1.0 - d / slot));',
|
'inten = max(inten, on * max(0.0, 1.0 - d / slot));',
|
||||||
|
'}',
|
||||||
'}',
|
'}',
|
||||||
'}',
|
'}',
|
||||||
// beyond ~55 km merge the pulses into a steady traffic glow:
|
// beyond ~55 km merge the pulses into a steady traffic glow:
|
||||||
@@ -2299,6 +2409,170 @@ function motorwayDistField() {
|
|||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chain OSM road segments into continuous routes by GLOBAL best-first
|
||||||
|
// stitching. Junctions are found PROXIMITY-based (not exact-node): many
|
||||||
|
// German motorways end each carriageway at its own node a few metres
|
||||||
|
// apart (bridges!), so exact-key matching severed chains at every
|
||||||
|
// overpass. A candidate join exists when two way-ends lie within ~130 m
|
||||||
|
// along the heading AND within ~32 m laterally (that lateral test is
|
||||||
|
// what keeps the two carriageways of a dual road from joining each
|
||||||
|
// other). Candidates are scored by directional continuity and processed
|
||||||
|
// best-first, so a mainline continuation always claims a junction before
|
||||||
|
// any ramp or angled fork can. Chains cap at ~580 km. Result: the A1
|
||||||
|
// Munich -> Vienna reads as ONE ribbon a light can traverse end to end.
|
||||||
|
var carMaxChainKm = 0;
|
||||||
|
var carRawCounts = [0, 0];
|
||||||
|
var carJoinCount = 0;
|
||||||
|
function carMergeChains(polys) {
|
||||||
|
var JOINED = 0;
|
||||||
|
var MAX_D = 45; // world units ~ 580 km
|
||||||
|
var NEAR = 0.03; // junction search radius (~390 m)
|
||||||
|
// two stitching tiers: TIGHT seams (same carriageway - bridge splits,
|
||||||
|
// shared nodes) claim junctions first; only still-open ends get the
|
||||||
|
// LOOSE pass (big interchange nodes where the mainline is cut ~150-300
|
||||||
|
// m apart with a small lateral jog). Lateral tolerance is what keeps
|
||||||
|
// parallel opposite carriageways from pairing with each other.
|
||||||
|
var TIERS = [
|
||||||
|
{ dot: 0.8, lat: 0.0012, alongLo: -0.0015, alongHi: 0.012 },
|
||||||
|
{ dot: 0.92, lat: 0.0035, alongLo: -0.004, alongHi: 0.026 }
|
||||||
|
];
|
||||||
|
var i, j, k;
|
||||||
|
var n = polys.length;
|
||||||
|
var wayLen = new Float64Array(n);
|
||||||
|
// endpoint records: position + unit vector pointing AWAY from the way
|
||||||
|
var eps = [];
|
||||||
|
var grid = {};
|
||||||
|
var cell = function (x, z) { return Math.floor(x / NEAR) + '_' + Math.floor(z / NEAR); };
|
||||||
|
for (i = 0; i < n; i++) {
|
||||||
|
var P = polys[i];
|
||||||
|
if (!P || P.length < 2) continue;
|
||||||
|
wayLen[i] = Math.max(P[0].d, P[P.length - 1].d);
|
||||||
|
var a = P[0], b = P[P.length - 1];
|
||||||
|
var ha = P.headD, ta = P.tailD;
|
||||||
|
if (!ha || !ta) {
|
||||||
|
var a2 = P[1], b2 = P[P.length - 2];
|
||||||
|
var al = Math.hypot(a.x - a2.x, a.z - a2.z) || 1;
|
||||||
|
var bl = Math.hypot(b.x - b2.x, b.z - b2.z) || 1;
|
||||||
|
ha = { x: (a.x - a2.x) / al, z: (a.z - a2.z) / al };
|
||||||
|
ta = { x: (b.x - b2.x) / bl, z: (b.z - b2.z) / bl };
|
||||||
|
}
|
||||||
|
var recs = [
|
||||||
|
{ w: i, e: 0, x: a.x, z: a.z, ax: ha.x, az: ha.z },
|
||||||
|
{ w: i, e: 1, x: b.x, z: b.z, ax: ta.x, az: ta.z }
|
||||||
|
];
|
||||||
|
for (k = 0; k < 2; k++) {
|
||||||
|
var r = recs[k];
|
||||||
|
var ck = cell(r.x, r.z);
|
||||||
|
(grid[ck] || (grid[ck] = [])).push(r);
|
||||||
|
eps.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// proximity candidate joins, per tier
|
||||||
|
var pairGeom = {};
|
||||||
|
function geomFor(A, B) {
|
||||||
|
var pk = A.w < B.w ? A.w + ':' + A.e + '|' + B.w + ':' + B.e
|
||||||
|
: B.w + ':' + B.e + '|' + A.w + ':' + A.e;
|
||||||
|
if (pairGeom[pk]) return pairGeom[pk];
|
||||||
|
var score = (-A.ax) * B.ax + (-A.az) * B.az;
|
||||||
|
var dirx = -A.ax, dirz = -A.az; // travel into junction
|
||||||
|
var gpx = B.x - A.x, gpz = B.z - A.z; // gap A.end -> B.start
|
||||||
|
var along = gpx * dirx + gpz * dirz;
|
||||||
|
var latx = gpx - along * dirx, latz = gpz - along * dirz;
|
||||||
|
var g = { s: score - Math.hypot(gpx, gpz) * 5, dot: score,
|
||||||
|
along: along, lat: Math.hypot(latx, latz) };
|
||||||
|
pairGeom[pk] = g;
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
var candsByTier = [[], []];
|
||||||
|
for (var t = 0; t < 2; t++) {
|
||||||
|
var T = TIERS[t];
|
||||||
|
var cands = candsByTier[t];
|
||||||
|
var seenPair = {};
|
||||||
|
for (i = 0; i < eps.length; i++) {
|
||||||
|
var A2 = eps[i];
|
||||||
|
var gx = Math.floor(A2.x / NEAR), gz = Math.floor(A2.z / NEAR);
|
||||||
|
for (var ox = -1; ox <= 1; ox++) {
|
||||||
|
for (var oz = -1; oz <= 1; oz++) {
|
||||||
|
var bucket = grid[(gx + ox) + '_' + (gz + oz)];
|
||||||
|
if (!bucket) continue;
|
||||||
|
for (j = 0; j < bucket.length; j++) {
|
||||||
|
var B2 = bucket[j];
|
||||||
|
if (B2.w === A2.w) continue;
|
||||||
|
var pk2 = A2.w < B2.w ? A2.w + ':' + A2.e + '|' + B2.w + ':' + B2.e
|
||||||
|
: B2.w + ':' + B2.e + '|' + A2.w + ':' + A2.e;
|
||||||
|
if (seenPair[pk2]) continue;
|
||||||
|
seenPair[pk2] = 1;
|
||||||
|
var gm = geomFor(A2, B2);
|
||||||
|
if (gm.dot <= T.dot) continue;
|
||||||
|
if (gm.lat > T.lat) continue; // opposite carriageway: reject
|
||||||
|
if (gm.along < T.alongLo || gm.along > T.alongHi) continue;
|
||||||
|
cands.push({ a: A2, b: B2, s: gm.s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cands.sort(function (p, q) { return q.s - p.s; });
|
||||||
|
}
|
||||||
|
// union-find over ways with open-end bookkeeping
|
||||||
|
var parent = new Int32Array(n);
|
||||||
|
for (i = 0; i < n; i++) parent[i] = i;
|
||||||
|
function find(v) { while (parent[v] !== v) { parent[v] = parent[parent[v]]; v = parent[v]; } return v; }
|
||||||
|
var open = new Array(n), link = new Array(n), chainLen = new Float64Array(n);
|
||||||
|
for (i = 0; i < n; i++) { open[i] = [1, 1]; link[i] = [null, null]; chainLen[i] = wayLen[i]; }
|
||||||
|
for (var tc = 0; tc < 2; tc++) {
|
||||||
|
var candsT = candsByTier[tc];
|
||||||
|
for (var c = 0; c < candsT.length; c++) {
|
||||||
|
var ca = candsT[c].a, cb = candsT[c].b;
|
||||||
|
if (!open[ca.w][ca.e] || !open[cb.w][cb.e]) continue;
|
||||||
|
var ra = find(ca.w), rb = find(cb.w);
|
||||||
|
if (ra === rb) continue; // would close a loop
|
||||||
|
if (chainLen[ra] + chainLen[rb] > MAX_D) continue;
|
||||||
|
link[ca.w][ca.e] = { w: cb.w, e: cb.e };
|
||||||
|
link[cb.w][cb.e] = { w: ca.w, e: ca.e };
|
||||||
|
open[ca.w][ca.e] = 0;
|
||||||
|
open[cb.w][cb.e] = 0;
|
||||||
|
parent[rb] = ra;
|
||||||
|
chainLen[ra] += chainLen[rb];
|
||||||
|
JOINED++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// walk each component from a free end, accumulating arc length
|
||||||
|
var out = [], started = new Uint8Array(n), maxD = 0;
|
||||||
|
for (i = 0; i < n; i++) {
|
||||||
|
if (started[i] || !polys[i] || polys[i].length < 2) continue;
|
||||||
|
var startSide = open[i][0] ? 0 : (open[i][1] ? 1 : -1);
|
||||||
|
if (startSide < 0) continue;
|
||||||
|
var chain = [], last = null, lastD = 0, cur = i, enter = startSide, guard = 0;
|
||||||
|
while (cur !== null && guard++ < 5000) {
|
||||||
|
started[cur] = 1;
|
||||||
|
var Q = polys[cur];
|
||||||
|
var s0 = enter === 1 ? Q.length - 1 : 0;
|
||||||
|
var st = enter === 1 ? -1 : 1;
|
||||||
|
for (var q = s0; q >= 0 && q < Q.length; q += st) {
|
||||||
|
var pt = Q[q];
|
||||||
|
var d = last === null ? pt.d : lastD + Math.hypot(pt.x - last.x, pt.z - last.z);
|
||||||
|
chain.push({ x: pt.x, z: pt.z, d: d });
|
||||||
|
last = pt; lastD = d;
|
||||||
|
}
|
||||||
|
var exit = enter === 0 ? 1 : 0;
|
||||||
|
var nxt = link[cur][exit];
|
||||||
|
if (nxt) { cur = nxt.w; enter = nxt.e; }
|
||||||
|
else cur = null;
|
||||||
|
}
|
||||||
|
if (chain.length > 1) {
|
||||||
|
var span = Math.max(chain[0].d, chain[chain.length - 1].d);
|
||||||
|
if (span > maxD) maxD = span;
|
||||||
|
out.push(chain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// isolated ways (no joins at all) still form a chain of one
|
||||||
|
for (i = 0; i < n; i++) {
|
||||||
|
if (!started[i] && polys[i] && polys[i].length > 1) out.push(polys[i]);
|
||||||
|
}
|
||||||
|
if (n) { carMaxChainKm = maxD * KM_PER_TILE; carJoinCount = JOINED; } // empty input must not reset the previous call's stats
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function buildCarwayVBO(gl) {
|
function buildCarwayVBO(gl) {
|
||||||
if (!CAR_ON || !gl || !carGeo || !terrainSample) return;
|
if (!CAR_ON || !gl || !carGeo || !terrainSample) return;
|
||||||
var ts = terrainSample;
|
var ts = terrainSample;
|
||||||
@@ -2373,10 +2647,17 @@ function buildCarwayVBO(gl) {
|
|||||||
gl.bindBuffer(gl.ARRAY_BUFFER, safe);
|
gl.bindBuffer(gl.ARRAY_BUFFER, safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
var m = upload(carVboM, build(carGeo.motorway, false));
|
// carGeo already carries MERGED chains: fetchMotorways stitches the
|
||||||
|
// raw ways once per data change and caches the chains (geo/roadchains)
|
||||||
|
var chainsM = carGeo.motorway;
|
||||||
|
var chainsB = carGeo.secondary;
|
||||||
|
var m = upload(carVboM, build(chainsM, false));
|
||||||
carVboM = m.vbo; carCountM = m.count;
|
carVboM = m.vbo; carCountM = m.count;
|
||||||
var bn = upload(carVboB, build(carGeo.secondary, true));
|
var bn = upload(carVboB, build(chainsB, true));
|
||||||
carVboB = bn.vbo; carCountB = bn.count;
|
carVboB = bn.vbo; carCountB = bn.count;
|
||||||
|
window.__carDbg = { motorwayIn: carRawCounts[0], secondaryIn: carRawCounts[1],
|
||||||
|
motorwayChains: chainsM.length, secondaryChains: chainsB.length,
|
||||||
|
maxChainKm: Math.round(carMaxChainKm * 10) / 10 };
|
||||||
sanitizeAttribSlots();
|
sanitizeAttribSlots();
|
||||||
|
|
||||||
if (!carProg && (carCountM || carCountB)) {
|
if (!carProg && (carCountM || carCountB)) {
|
||||||
@@ -2461,6 +2742,67 @@ function runPool(entries, lanes, worker) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- Cacheable derived geometry -----------------------------------------
|
||||||
|
// Raw Overpass JSON already lives in IndexedDB forever; the expensive
|
||||||
|
// post-processing (resample + chain stitching) is PERSISTED too and only
|
||||||
|
// redone when a cell is actually (re)fetched. A cellsFp() fingerprint over
|
||||||
|
// "id:savedAt" of every source record detects that; the fingerprint is
|
||||||
|
// built from record metadata only - no JSON parsing needed to check it.
|
||||||
|
function cellsFp(parts) {
|
||||||
|
var s = parts.slice().sort().join('|');
|
||||||
|
var h = 2166136261;
|
||||||
|
for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||||
|
return (h >>> 0) + ':' + parts.length;
|
||||||
|
}
|
||||||
|
function u8ToB64(u8) {
|
||||||
|
var s = '';
|
||||||
|
for (var i = 0; i < u8.length; i += 8192)
|
||||||
|
s += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + 8192, u8.length)));
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
function b64ToU8(b64) {
|
||||||
|
var s = atob(b64), u = new Uint8Array(s.length);
|
||||||
|
for (var i = 0; i < s.length; i++) u[i] = s.charCodeAt(i);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
function f32ToB64(f) { return u8ToB64(new Uint8Array(f.buffer, f.byteOffset, f.byteLength)); }
|
||||||
|
function b64ToF32(b64) { return new Float32Array(b64ToU8(b64).buffer); }
|
||||||
|
function encodeChains(chains) {
|
||||||
|
var cnts = [], total = 0, i, j;
|
||||||
|
for (i = 0; i < chains.length; i++) { cnts.push(chains[i].length); total += chains[i].length; }
|
||||||
|
var f = new Float32Array(total * 3), k = 0;
|
||||||
|
for (i = 0; i < chains.length; i++)
|
||||||
|
for (j = 0; j < chains[i].length; j++) {
|
||||||
|
var p = chains[i][j];
|
||||||
|
f[k++] = p.x; f[k++] = p.z; f[k++] = p.d;
|
||||||
|
}
|
||||||
|
return { cnts: cnts, data: f32ToB64(f) };
|
||||||
|
}
|
||||||
|
function decodeChains(cnts, b64) {
|
||||||
|
var f = b64ToF32(b64), out = [], k = 0;
|
||||||
|
for (var i = 0; i < cnts.length; i++) {
|
||||||
|
var arr = [];
|
||||||
|
for (var j = 0; j < cnts[i]; j++) { arr.push({ x: f[k], z: f[k + 1], d: f[k + 2] }); k += 3; }
|
||||||
|
out.push(arr);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function encodePlaces(list) {
|
||||||
|
var f = new Float32Array(list.length * 4);
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
f[i * 4] = list[i].x; f[i * 4 + 1] = list[i].z;
|
||||||
|
f[i * 4 + 2] = list[i].rank; f[i * 4 + 3] = list[i].pop;
|
||||||
|
}
|
||||||
|
return f32ToB64(f);
|
||||||
|
}
|
||||||
|
function decodePlaces(cnt, b64) {
|
||||||
|
var f = b64ToF32(b64), out = [];
|
||||||
|
for (var i = 0; i < cnt; i++)
|
||||||
|
out.push({ x: f[i * 4], z: f[i * 4 + 1], rank: Math.round(f[i * 4 + 2]), pop: f[i * 4 + 3] });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// same guard as the car rebuild: after deleting the old city VBO every
|
// same guard as the car rebuild: after deleting the old city VBO every
|
||||||
// still-enabled attrib slot must point at a live buffer, or the next
|
// still-enabled attrib slot must point at a live buffer, or the next
|
||||||
// full-screen draw warns "no buffer is bound to enabled attribute".
|
// full-screen draw warns "no buffer is bound to enabled attribute".
|
||||||
|
|||||||
Reference in new issue
Block a user