doxa-maps/library/utils/geoUtils.js
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

265 lines
9.1 KiB
JavaScript

/**
* geoUtils.js — Geographic / GeoJSON Utility Functions
*
* Pure functions — no Vue, no Mapbox dependency.
* Safe to use in composables, stores, or profile components.
*
* Origin: doxa-research-mfe (research wins on shared symbols).
* Symbol-merged from doxa-simple-map-mfe: calculateZoomForBounds,
* darkenColor, isValidCoordinate, validateGeoJSONFeature,
* validatePeopleGroup, clampZoom, isValidHexColor.
*/
/**
* Extract all coordinates from a GeoJSON geometry.
* @param {Object} geometry - GeoJSON geometry (Polygon or MultiPolygon)
* @param {Array} coordsArray - Array to push [lng, lat] pairs into
*/
export function extractCoordinates(geometry, coordsArray) {
if (!geometry || !coordsArray) return
if (geometry.type === 'Polygon') {
geometry.coordinates[0].forEach(coord => coordsArray.push(coord))
} else if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach(polygon => {
polygon[0].forEach(coord => coordsArray.push(coord))
})
}
}
/**
* Calculate the centroid of an array of points.
* @param {Array} points - Objects with `longitude` and `latitude` properties
* @returns {Array} [lng, lat]
*/
export function calculateCentroid(points) {
if (!points?.length) return [0, 0]
let sumLon = 0, sumLat = 0
points.forEach(p => { sumLon += p.longitude; sumLat += p.latitude })
return [sumLon / points.length, sumLat / points.length]
}
/**
* Calculate a bounding box for an array of points.
* @param {Array} points - Objects with `longitude` and `latitude` properties
* @returns {{ minLon, maxLon, minLat, maxLat } | null}
*/
export function calculateBounds(points) {
if (!points?.length) return null
let minLon = Infinity, maxLon = -Infinity
let minLat = Infinity, maxLat = -Infinity
points.forEach(p => {
minLon = Math.min(minLon, p.longitude)
maxLon = Math.max(maxLon, p.longitude)
minLat = Math.min(minLat, p.latitude)
maxLat = Math.max(maxLat, p.latitude)
})
return { minLon, maxLon, minLat, maxLat }
}
/**
* Convert a bounds object to a Mapbox LngLatBoundsLike array.
* @param {{ minLon, maxLon, minLat, maxLat }} bounds
* @returns {Array} [[minLon, minLat], [maxLon, maxLat]]
*/
export function boundsToLngLatBounds(bounds) {
if (!bounds) return null
return [[bounds.minLon, bounds.minLat], [bounds.maxLon, bounds.maxLat]]
}
/**
* Check if a [lng, lat] coordinate is valid (within world bounds).
* @param {Array} coord - [lng, lat]
* @returns {boolean}
*/
export function isValidCoord(coord) {
if (!Array.isArray(coord) || coord.length < 2) return false
const [lng, lat] = coord
return lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90
}
/**
* Build a GeoJSON FeatureCollection from an array of point records.
* @param {Array} records - Objects with `longitude`, `latitude`, and any properties
* @param {Function} [getProps] - (record) => properties object. Defaults to spreading the record.
* @returns {Object} GeoJSON FeatureCollection
*/
export function toPointFeatureCollection(records, getProps) {
const features = records
.filter(r => r.longitude != null && r.latitude != null)
.map(r => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [r.longitude, r.latitude]
},
properties: getProps ? getProps(r) : { ...r }
}))
return { type: 'FeatureCollection', features }
}
// ─── Ported from doxa-map-mfe geoUtils.js (Friction fix during research-mfe build) ──
/**
* Generate a deterministic HSL color from a string (used by language-family
* legend coloring fallback when no palette entry exists).
* @param {string} str
* @returns {string} `hsl(h,60%,50%)`
*/
export function generateColorFromString(str) {
if (!str) return 'hsl(0, 60%, 50%)'
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
const hue = Math.abs(hash % 360)
return `hsl(${hue}, 60%, 50%)`
}
// ─── Symbol-merged from simple-map (missing in research) ───
// Origin: simple-map
/**
* Calculate appropriate zoom level for given bounds.
* Conservative — zooms out more to show all points.
* @param {Object} bounds - Bounds object from calculateBounds
* @returns {number} Zoom level (1-4.5)
*/
export function calculateZoomForBounds(bounds) {
if (!bounds) return 3
const lonSpread = bounds.maxLon - bounds.minLon
const latSpread = bounds.maxLat - bounds.minLat
const maxSpread = Math.max(lonSpread, latSpread)
if (maxSpread > 120) return 1
if (maxSpread > 80) return 1.5
if (maxSpread > 50) return 2
if (maxSpread > 30) return 2.5
if (maxSpread > 20) return 3
if (maxSpread > 10) return 3.5
if (maxSpread > 5) return 4
return 4.5
}
// Origin: simple-map
/**
* Darken a hex color by a percentage. Includes hardcoded overrides for
* specific region colors so legend lines stay visible against fills.
* @param {string} color - Hex color (e.g., '#e74c3c')
* @param {number} percent - Percentage to darken (0-100), default 15
* @returns {string} Darkened hex color
*/
export function darkenColor(color, percent = 15) {
if (!color || typeof color !== 'string') {
console.error('darkenColor: Invalid color input:', color)
return '#000000'
}
// Hardcoded region overrides (East Asia, Africa, Latin America,
// Eurasia/Europe, North America, Middle East)
if (color === '#3498db') return '#2171b5'
if (color === '#f03b2d') return '#c0392b'
if (color === '#e74c3c') return '#c0392b'
if (color === '#f39c12') return '#ca6f1e'
if (color === '#f0c64a') return '#c9a227' // prayer yellow → darker yellow
if (color === '#2ecc71') return '#229954'
if (color === '#1abc9c') return '#138d75'
if (color === '#9b59b6') return '#7d3c98'
const hex = color.replace('#', '')
if (hex.length !== 6 || !/^[0-9A-Fa-f]{6}$/.test(hex)) {
console.error('darkenColor: Invalid hex color:', color)
return '#000000'
}
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
if (isNaN(r) || isNaN(g) || isNaN(b)) {
console.error('darkenColor: Failed to parse RGB from:', color)
return '#000000'
}
const factor = (100 - percent) / 100
const newR = Math.max(0, Math.floor(r * factor))
const newG = Math.max(0, Math.floor(g * factor))
const newB = Math.max(0, Math.floor(b * factor))
const toHex = (n) => {
const hexVal = n.toString(16)
return hexVal.length === 1 ? '0' + hexVal : hexVal
}
return `#${toHex(newR)}${toHex(newG)}${toHex(newB)}`
}
// Origin: simple-map
/**
* Validate a coordinate pair (numeric form).
* @param {number} lon - Longitude (-180 to 180)
* @param {number} lat - Latitude (-90 to 90)
* @returns {boolean} True if valid
*/
export function isValidCoordinate(lon, lat) {
return (
typeof lon === 'number' && !isNaN(lon) && lon >= -180 && lon <= 180 &&
typeof lat === 'number' && !isNaN(lat) && lat >= -90 && lat <= 90
)
}
// Origin: simple-map
/**
* Validate a GeoJSON feature.
* @param {Object} feature - GeoJSON feature to validate
* @returns {{ valid: boolean, errors: string[] }}
*/
export function validateGeoJSONFeature(feature) {
const errors = []
if (!feature) return { valid: false, errors: ['Feature is null or undefined'] }
if (feature.type !== 'Feature') {
errors.push(`Invalid type: ${feature.type}, expected 'Feature'`)
}
if (!feature.geometry) {
errors.push('Missing geometry')
} else {
const validTypes = ['Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon']
if (!validTypes.includes(feature.geometry.type)) {
errors.push(`Invalid geometry type: ${feature.geometry.type}`)
}
if (!feature.geometry.coordinates) {
errors.push('Missing coordinates')
}
}
return { valid: errors.length === 0, errors }
}
// Origin: simple-map
/**
* Validate a people-group-shaped object has required fields.
* @param {Object} pg - People group object
* @param {string[]} requiredFields - Fields to check for
* @returns {{ valid: boolean, missing: string[] }}
*/
export function validatePeopleGroup(pg, requiredFields = ['id', 'name', 'latitude', 'longitude']) {
if (!pg) return { valid: false, missing: ['entire object'] }
const missing = requiredFields.filter(field => {
const value = pg[field] ?? pg._normalized?.[field]
return value === undefined || value === null || value === ''
})
return { valid: missing.length === 0, missing }
}
// Origin: simple-map
/**
* Clamp a zoom level into the acceptable range.
* @param {number} zoom - Zoom level to validate
* @param {number} min - Minimum zoom (default 0)
* @param {number} max - Maximum zoom (default 22 for Mapbox)
* @returns {number} Clamped zoom value
*/
export function clampZoom(zoom, min = 0, max = 22) {
if (typeof zoom !== 'number' || isNaN(zoom)) return min
return Math.max(min, Math.min(max, zoom))
}
// Origin: simple-map
/**
* Validate hex color format (#RGB or #RRGGBB).
* @param {string} color - Color string to validate
* @returns {boolean} True if valid hex color
*/
export function isValidHexColor(color) {
if (typeof color !== 'string') return false
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color)
}