Some checks are pending
build-gate / build (push) Waiting to run
Driver 2026-09-21: 'a folder called deployment which will store the capacitor
folder, wordpress folder and tauri folder', and 'a folder called documentation
to store the contributing folder so docs are in one place'.
docs/ + contributing/ + CONTRIBUTING.md -> documentation/ (three doc homes into
one). capacitor/ + wordpress/ + src-tauri/ -> deployment/.
The public/ -> dist/ rename the Driver asked about is NOT here: after seeing the
measured blast radius (855 tracked files, 54 source refs, and public/ is the
served web-root four consumers point at) the Driver withdrew it - 'keep the
public folder the same'.
Path references were rewritten mechanically (258 across 87 files), but three
classes needed hand fixes because a prefix regex cannot see them:
- join(ROOT,'docs',...) in 10 internal scripts - a quoted SEGMENT, not a prefix
- tauri frontendDist '../public' -> '../../public', since src-tauri is now one
level deeper; check:tauri caught this exactly as the card predicted
- the WordPress contract test's dirname(__DIR__,2) -> 3, same reason
Also reverted one over-reach: the rewrite renamed a FINDING-CATEGORY label
'docs' in public-hygiene-check.mjs, which is a rule name in its own header, not
a path.
Verified: full build ALL GUARDS PASS, SRI consistent; check:tauri, check:mobile
(cap sync + webDir OK), check:wp-embed, quoted-scripts, capability-citations,
capability-exemplars, library-index, public-hygiene, parse and drop all pass.
243 lines
6.7 KiB
JavaScript
243 lines
6.7 KiB
JavaScript
/**
|
||
* ClusterHelpers.js — Pure helpers shared across clustering modes.
|
||
*
|
||
* Mode-agnostic geometric helpers used by:
|
||
* - useMapClustering.js (composable)
|
||
* - MSTClusteringUtil.js (Kruskal MST)
|
||
* - NetworkClusteringUtil.js (nearest-neighbor)
|
||
*
|
||
* Convex hull is extracted from the upstream PolygonClusteringFeature module.
|
||
* Centroid + distance helpers are extracted to avoid duplication across the
|
||
* clustering modules.
|
||
*
|
||
* No external deps. Browser-safe ES module.
|
||
*
|
||
* @module utils/ClusterHelpers
|
||
*/
|
||
|
||
/**
|
||
* @typedef {[number, number]} LngLat
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} GeoPoint
|
||
* @property {number|string} latitude
|
||
* @property {number|string} longitude
|
||
*/
|
||
|
||
/**
|
||
* Euclidean distance in degree-space between two (lat,lng) pairs.
|
||
* Fast and good enough for visualization at world scale; do NOT use for
|
||
* great-circle navigation.
|
||
*
|
||
* @param {number} lat1
|
||
* @param {number} lng1
|
||
* @param {number} lat2
|
||
* @param {number} lng2
|
||
* @returns {number} distance in degrees
|
||
*/
|
||
export function euclideanDistance(lat1, lng1, lat2, lng2) {
|
||
const a = parseFloat(lng1) - parseFloat(lng2);
|
||
const b = parseFloat(lat1) - parseFloat(lat2);
|
||
return Math.sqrt(a * a + b * b);
|
||
}
|
||
|
||
/**
|
||
* Distance between two GeoPoint objects using `latitude`/`longitude` props.
|
||
*
|
||
* @param {GeoPoint} p1
|
||
* @param {GeoPoint} p2
|
||
* @returns {number} distance in degrees
|
||
*/
|
||
export function pointDistance(p1, p2) {
|
||
return euclideanDistance(
|
||
parseFloat(p1.latitude),
|
||
parseFloat(p1.longitude),
|
||
parseFloat(p2.latitude),
|
||
parseFloat(p2.longitude)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Centroid (arithmetic mean) of an array of GeoPoints.
|
||
* Returns [lng, lat] suitable for use as a Mapbox cluster anchor.
|
||
*
|
||
* @param {GeoPoint[]} points
|
||
* @returns {LngLat} [lng, lat] — defaults to [0, 0] for empty input
|
||
*/
|
||
export function pointsCentroid(points) {
|
||
if (!points?.length) return [0, 0];
|
||
let sumLng = 0;
|
||
let sumLat = 0;
|
||
let n = 0;
|
||
for (const p of points) {
|
||
const lng = parseFloat(p.longitude);
|
||
const lat = parseFloat(p.latitude);
|
||
if (isNaN(lng) || isNaN(lat)) continue;
|
||
sumLng += lng;
|
||
sumLat += lat;
|
||
n++;
|
||
}
|
||
if (n === 0) return [0, 0];
|
||
return [sumLng / n, sumLat / n];
|
||
}
|
||
|
||
/**
|
||
* Cross product of vectors OA × OB (2D z-component).
|
||
* Positive → counter-clockwise turn. Negative → clockwise.
|
||
*
|
||
* @param {LngLat} o
|
||
* @param {LngLat} a
|
||
* @param {LngLat} b
|
||
* @returns {number}
|
||
*/
|
||
export function crossProduct(o, a, b) {
|
||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||
}
|
||
|
||
/**
|
||
* Convex hull via Graham scan. Useful for polygon-style cluster wrappers.
|
||
* Mutates `points` order — pass a copy if that matters.
|
||
*
|
||
* Returns an open ring (first point NOT repeated as last). For Mapbox Polygon
|
||
* geometry, append `hull[0]` to close the ring before adding to the source.
|
||
*
|
||
* @param {LngLat[]} points
|
||
* @returns {LngLat[]} hull points in counter-clockwise order
|
||
*/
|
||
export function convexHull(points) {
|
||
if (points.length < 3) return points;
|
||
|
||
let lowest = 0;
|
||
for (let i = 1; i < points.length; i++) {
|
||
if (points[i][1] < points[lowest][1] ||
|
||
(points[i][1] === points[lowest][1] && points[i][0] < points[lowest][0])) {
|
||
lowest = i;
|
||
}
|
||
}
|
||
|
||
[points[0], points[lowest]] = [points[lowest], points[0]];
|
||
const pivot = points[0];
|
||
|
||
const sorted = points.slice(1).sort((a, b) => {
|
||
const angleA = Math.atan2(a[1] - pivot[1], a[0] - pivot[0]);
|
||
const angleB = Math.atan2(b[1] - pivot[1], b[0] - pivot[0]);
|
||
if (angleA !== angleB) return angleA - angleB;
|
||
const distA = (a[0] - pivot[0]) ** 2 + (a[1] - pivot[1]) ** 2;
|
||
const distB = (b[0] - pivot[0]) ** 2 + (b[1] - pivot[1]) ** 2;
|
||
return distA - distB;
|
||
});
|
||
|
||
const hull = [pivot];
|
||
for (const point of sorted) {
|
||
while (hull.length > 1 &&
|
||
crossProduct(hull[hull.length - 2], hull[hull.length - 1], point) <= 0) {
|
||
hull.pop();
|
||
}
|
||
hull.push(point);
|
||
}
|
||
|
||
return hull;
|
||
}
|
||
|
||
/**
|
||
* Group an array of records by a derived key.
|
||
*
|
||
* @template T
|
||
* @param {T[]} items
|
||
* @param {(item: T) => string} keyFn
|
||
* @returns {Object<string, T[]>}
|
||
*
|
||
* @example
|
||
* const groups = groupBy(peopleGroups, pg => pg.languageFamily ?? 'Unknown');
|
||
*/
|
||
export function groupBy(items, keyFn) {
|
||
const out = {};
|
||
for (const item of items) {
|
||
const key = keyFn(item);
|
||
if (!out[key]) out[key] = [];
|
||
out[key].push(item);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Coerce a record's lat/lng to numbers and validate.
|
||
* Returns null if either coordinate is missing or NaN.
|
||
*
|
||
* @param {GeoPoint} p
|
||
* @returns {{lat: number, lng: number}|null}
|
||
*/
|
||
export function toLatLng(p) {
|
||
if (!p) return null;
|
||
const lat = parseFloat(p.latitude);
|
||
const lng = parseFloat(p.longitude);
|
||
if (isNaN(lat) || isNaN(lng)) return null;
|
||
return { lat, lng };
|
||
}
|
||
|
||
/**
|
||
* Build a GeoJSON LineString feature from two GeoPoints with optional props.
|
||
*
|
||
* @param {GeoPoint} from
|
||
* @param {GeoPoint} to
|
||
* @param {Object} [properties]
|
||
* @returns {Object|null} GeoJSON Feature or null if either point is invalid
|
||
*/
|
||
export function makeLineFeature(from, to, properties = {}) {
|
||
const a = toLatLng(from);
|
||
const b = toLatLng(to);
|
||
if (!a || !b) return null;
|
||
return {
|
||
type: 'Feature',
|
||
geometry: {
|
||
type: 'LineString',
|
||
coordinates: [[a.lng, a.lat], [b.lng, b.lat]]
|
||
},
|
||
properties
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Default Mapbox cluster source options. Used by useMapClustering's 'mapbox'
|
||
* mode. Tuned for the typical 1k–20k people-group dataset.
|
||
*
|
||
* @type {{cluster: boolean, clusterMaxZoom: number, clusterRadius: number}}
|
||
*/
|
||
// clusterMaxZoom MUST sit a few levels below the research map's zoom-in cap
|
||
// (RESEARCH_MAX_ZOOM = 10 in research-map.vue). At 14 it was above the cap, so
|
||
// clusters never broke apart and overlapping pins stayed permanently un-clickable
|
||
// (COMMIT-LOG #8). At 6 pins decluster well before the user hits the limit, and the
|
||
// tighter radius (40) lets dense areas like South Asia separate instead of merging.
|
||
export const DEFAULT_MAPBOX_CLUSTER_OPTIONS = Object.freeze({
|
||
cluster: true,
|
||
clusterMaxZoom: 6,
|
||
clusterRadius: 40
|
||
});
|
||
|
||
/**
|
||
* Default cluster color ramp (count-based step expression). Matches the
|
||
* worked example in documentation/mapbox/clustering.md.
|
||
*
|
||
* @type {Array}
|
||
*/
|
||
export const DEFAULT_CLUSTER_COLOR_RAMP = Object.freeze([
|
||
'step', ['get', 'point_count'],
|
||
'#51bbd6',
|
||
10, '#f1f075',
|
||
50, '#f28cb1',
|
||
500, '#e74c3c'
|
||
]);
|
||
|
||
/**
|
||
* Default cluster radius ramp (count-based step expression).
|
||
*
|
||
* @type {Array}
|
||
*/
|
||
export const DEFAULT_CLUSTER_RADIUS_RAMP = Object.freeze([
|
||
'step', ['get', 'point_count'],
|
||
15,
|
||
10, 20,
|
||
50, 25,
|
||
500, 35
|
||
]);
|