// Keeps the stored subscriptions-union GeoJSON below a size that Mongo (and the // DDP connection that ships it to every client) can actually take. // // The union of every subscription circle is stored as a JSON string inside a // single `siteSettings` document. A BSON document is capped at 16 MiB, so past // some number of subscriptions the upsert would simply start failing and the map // would silently freeze at the last union that fit — which is exactly the kind of // scale bug nobody notices until production. On top of the hard Mongo limit, that // same string is published to browsers, so multi-megabyte unions are already a // problem well before 16 MiB. // // The chosen fix is progressive degradation instead of failure: the geometry is // coarsened, in visually-cheapest-first order, until it fits. The zone union is a // decorative overlay ("roughly where people are watching"), never a source of // truth for alerts, so losing precision is acceptable; losing the whole overlay is // not. Every degradation is reported back so the caller can store it alongside the // union and ops can see it happening. // // The ladder, in order: // 1. coordinate precision 6 -> 5 -> 4 -> 3 decimals (~0.1 m -> ~100 m), dropping // points that collapse onto their neighbour after rounding. // 2. point decimation, keeping every k-th vertex of each ring (circles are // generated with 144 steps, so there is a lot of slack here). // 3. dropping holes (inner rings) — visually the least missed. // 4. dropping the smallest polygons of the MultiPolygon until it fits. // If even a single polygon does not fit, null is returned rather than storing // something Mongo will reject. // 16 MiB is Mongo's hard limit for the whole document; this cap is deliberately // well below it because the same value travels over DDP to every connected // browser. Raise it only together with a plan for the client side. export const MAX_UNION_BYTES = 8 * 1024 * 1024; const MIN_RING_POINTS = 12; const DECIMATION_FACTORS = [2, 3, 4, 6, 8]; const PRECISIONS = [5, 4, 3]; export const byteLength = json => Buffer.byteLength(json, 'utf8'); const geometryOf = union => (union && union.type === 'Feature' ? union.geometry : union); // Both Polygon and MultiPolygon, seen as a list of polygons (a polygon being a // list of rings, the first one the outer one). const polygonsOf = (geometry) => { if (!geometry || !geometry.coordinates) return []; return geometry.type === 'MultiPolygon' ? geometry.coordinates : [geometry.coordinates]; }; const withPolygons = (union, polygons) => { const geometry = geometryOf(union); const coordinates = geometry.type === 'MultiPolygon' ? polygons : polygons[0]; const newGeometry = { ...geometry, coordinates }; return union.type === 'Feature' ? { ...union, geometry: newGeometry } : newGeometry; }; // A ring is only a ring if it closes and has at least three distinct corners. const closeRing = (ring) => { if (ring.length < 3) return null; const [firstLon, firstLat] = ring[0]; const [lastLon, lastLat] = ring[ring.length - 1]; const closed = firstLon === lastLon && firstLat === lastLat ? ring : [...ring, [firstLon, firstLat]]; return closed.length >= 4 ? closed : null; }; const roundRing = (ring, factor) => { const out = []; for (let i = 0; i < ring.length; i += 1) { const lon = Math.round(ring[i][0] * factor) / factor; const lat = Math.round(ring[i][1] * factor) / factor; const prev = out[out.length - 1]; if (!prev || prev[0] !== lon || prev[1] !== lat) out.push([lon, lat]); } return closeRing(out); }; const decimateRing = (ring, keepEvery) => { if (ring.length <= MIN_RING_POINTS) return ring; const out = []; for (let i = 0; i < ring.length - 1; i += keepEvery) out.push(ring[i]); return closeRing(out) || ring; }; // Rings that survive a transformation keep the polygon alive; a polygon whose // outer ring collapsed is dropped entirely (its holes are meaningless then). const mapPolygons = (polygons, mapRing) => polygons .map(rings => rings.map(mapRing).filter(Boolean)) .filter(rings => rings.length > 0); const ringBboxArea = (ring) => { let minLon = Infinity; let minLat = Infinity; let maxLon = -Infinity; let maxLat = -Infinity; for (let i = 0; i < ring.length; i += 1) { if (ring[i][0] < minLon) [minLon] = ring[i]; if (ring[i][0] > maxLon) [maxLon] = ring[i]; if (ring[i][1] < minLat) [, minLat] = ring[i]; if (ring[i][1] > maxLat) [, maxLat] = ring[i]; } return (maxLon - minLon) * (maxLat - minLat); }; /** * Returns the largest-fidelity version of `union` whose JSON fits in `maxBytes`, * together with that JSON (callers always need the string anyway, and * re-stringifying a multi-megabyte object on the main thread is not free). * * @returns {{ union: object|null, json: string, bytes: number, * degraded: null | { originalBytes: number, steps: string[] } }} */ const limitUnionSize = (union, maxBytes = MAX_UNION_BYTES) => { let json = JSON.stringify(union === undefined ? null : union); let bytes = byteLength(json); if (union === null || union === undefined || bytes <= maxBytes) { return { union: union === undefined ? null : union, json, bytes, degraded: null }; } const originalBytes = bytes; const steps = []; let current = union; const attempt = (step, transform) => { const next = transform(); if (!next) return false; current = next; steps.push(step); json = JSON.stringify(current); bytes = byteLength(json); return bytes <= maxBytes; }; const done = () => ({ union: current, json, bytes, degraded: { originalBytes, steps } }); for (let i = 0; i < PRECISIONS.length; i += 1) { const precision = PRECISIONS[i]; const factor = 10 ** precision; const fits = attempt(`precision:${precision}`, () => { const polygons = mapPolygons(polygonsOf(geometryOf(current)), ring => roundRing(ring, factor)); return polygons.length > 0 ? withPolygons(current, polygons) : null; }); if (fits) return done(); } for (let i = 0; i < DECIMATION_FACTORS.length; i += 1) { const keepEvery = DECIMATION_FACTORS[i]; const fits = attempt(`decimate:${keepEvery}`, () => { const polygons = mapPolygons(polygonsOf(geometryOf(union)), ring => decimateRing( roundRing(ring, 10 ** PRECISIONS[PRECISIONS.length - 1]) || ring, keepEvery )); return polygons.length > 0 ? withPolygons(current, polygons) : null; }); if (fits) return done(); } const fitsWithoutHoles = attempt('holes', () => { const polygons = polygonsOf(geometryOf(current)).map(rings => [rings[0]]); return polygons.length > 0 ? withPolygons(current, polygons) : null; }); if (fitsWithoutHoles) return done(); // Last resort: drop the smallest polygons first, so what remains is the part of // the map a user is most likely to be looking at. Binary search for how many // survive — trying one fewer at a time means re-serializing a multi-megabyte // object hundreds of times, which is precisely the kind of main-thread stall // this whole area is trying to get rid of. const ordered = polygonsOf(geometryOf(current)) .map(rings => ({ rings, area: ringBboxArea(rings[0]) })) .sort((a, b) => b.area - a.area) .map(p => p.rings); const fitsWith = (keep) => { const candidate = withPolygons(current, ordered.slice(0, keep)); const candidateJson = JSON.stringify(candidate); return byteLength(candidateJson) <= maxBytes ? { candidate, candidateJson } : null; }; let low = 1; let high = ordered.length - 1; let best = null; while (low <= high) { const mid = Math.floor((low + high) / 2); const fit = fitsWith(mid); if (fit) { best = { keep: mid, ...fit }; low = mid + 1; } else { high = mid - 1; } } if (best) { current = best.candidate; json = best.candidateJson; bytes = byteLength(json); steps.push(`polygons:${best.keep}`); return done(); } // A single polygon that still does not fit means something is very wrong // upstream; storing nothing beats an upsert that Mongo rejects on every run. steps.push('dropped'); current = null; json = 'null'; bytes = byteLength(json); return done(); }; export default limitUnionSize;