diff --git a/src/db/AreaSchema.ts b/src/db/AreaSchema.ts index 21735dbd..e6582248 100644 --- a/src/db/AreaSchema.ts +++ b/src/db/AreaSchema.ts @@ -44,10 +44,11 @@ const MetadataSchema = new Schema({ isBoulder: { type: Boolean, default: false }, lnglat: { type: PointSchema, - index: '2dsphere' + index: '2dsphere', + required: false }, polygon: polygonSchema, - bbox: [{ type: Number, required: true }], + bbox: [{ type: Number, required: false }], leftRightIndex: { type: Number, required: false }, ext_id: { type: String, required: false, index: true }, area_id: { diff --git a/src/db/AreaTypes.ts b/src/db/AreaTypes.ts index ff478293..1fc5e5eb 100644 --- a/src/db/AreaTypes.ts +++ b/src/db/AreaTypes.ts @@ -73,7 +73,7 @@ export interface IAreaProps extends AuthorMetadata { * computed aggregations on this document. See the AggregateType documentation for * more information. */ - aggregate?: AggregateType + aggregate: AggregateType /** * User-composed content that makes up most of the user-readable data in the system. * See the IAreaContent documentation for more information. @@ -120,12 +120,12 @@ export interface IAreaMetadata { /** * Location of a wall or a boulder aka leaf node. Use `bbox` or `polygon` non-leaf areas. * */ - lnglat: Point + lnglat?: Point /** * The smallest possible bounding box (northwest and southeast coordinates) that contains * all of this areas children (Both sub-areas and climbs). */ - bbox: BBox + bbox?: BBox /** * Left-to-right sorting index. Undefined or -1 for unsorted area, diff --git a/src/db/ClimbTypes.ts b/src/db/ClimbTypes.ts index 4d0acf1b..5b39fa62 100644 --- a/src/db/ClimbTypes.ts +++ b/src/db/ClimbTypes.ts @@ -137,7 +137,7 @@ export interface DisciplineType { tr?: boolean } export interface IClimbMetadata { - lnglat: Point + lnglat?: Point left_right_index?: number /** mountainProject ID (if this climb was sourced from mountainproject) */ mp_id?: string diff --git a/src/db/MediaObjectSchema.ts b/src/db/MediaObjectSchema.ts index 84a2fa0b..a2078d9f 100644 --- a/src/db/MediaObjectSchema.ts +++ b/src/db/MediaObjectSchema.ts @@ -17,7 +17,8 @@ const EntitySchema = new Schema({ ancestors: { type: Schema.Types.String, required: true, index: true }, lnglat: { type: PointSchema, - index: '2dsphere' + index: '2dsphere', + required: false } }, { _id: true }) diff --git a/src/db/MediaObjectTypes.ts b/src/db/MediaObjectTypes.ts index 9a56417e..b668bbfd 100644 --- a/src/db/MediaObjectTypes.ts +++ b/src/db/MediaObjectTypes.ts @@ -23,7 +23,7 @@ export interface EntityTag { ancestors: string climbName?: string areaName: string - lnglat: Point + lnglat?: Point } export interface MediaByUsers { diff --git a/src/db/export/Typesense/TypesenseSchemas.ts b/src/db/export/Typesense/TypesenseSchemas.ts index 5a6483f9..fe935d82 100644 --- a/src/db/export/Typesense/TypesenseSchemas.ts +++ b/src/db/export/Typesense/TypesenseSchemas.ts @@ -9,7 +9,7 @@ export interface ClimbTypeSenseItem { disciplines: string[] grade?: string // Todo: switch to grade context safety: string - cragLatLng: [number, number] + cragLatLng?: [number, number] } /** @@ -78,7 +78,7 @@ export interface AreaTypeSenseItem { name: string pathTokens: string[] areaUUID: string - areaLatLng: [number, number] + areaLatLng?: [number, number] leaf: boolean isDestination: boolean totalClimbs: number diff --git a/src/db/export/Typesense/Utils.ts b/src/db/export/Typesense/Utils.ts index 2165d0ae..3b0241cc 100644 --- a/src/db/export/Typesense/Utils.ts +++ b/src/db/export/Typesense/Utils.ts @@ -45,7 +45,10 @@ export const disciplinesToArray = (type: DisciplineType): any => { * @param geoPoint * @returns */ -export const geoToLatLng = (geoPoint: Point): [number, number] => { +export const geoToLatLng = (geoPoint?: Point): [number, number] | undefined => { + if (geoPoint == null) { + return undefined + } const { coordinates } = geoPoint return [coordinates[1], coordinates[0]] } diff --git a/src/db/utils/Aggregate.ts b/src/db/utils/Aggregate.ts index 9f490fe8..6917a7a3 100644 --- a/src/db/utils/Aggregate.ts +++ b/src/db/utils/Aggregate.ts @@ -80,17 +80,23 @@ export const aggregateCragStats = (crag: AreaType): AggregateType => { const byGrade: Record | {} = {} const disciplines: CountByDisciplineType = {} + const DEFAULT = { + byGrade: [], + byDiscipline: disciplines, + byGradeBand: { + ...INIT_GRADEBAND + } + } + + if ((crag.climbs?.length ?? 0) === 0) { + return DEFAULT + } + // Assumption: all climbs use the crag's grade context const cragGradeScales = gradeContextToGradeScales[crag.gradeContext] if (cragGradeScales == null) { logger.warn(`Area ${crag.area_name} (${crag.metadata.area_id.toUUID().toString()}) has invalid grade context: '${crag.gradeContext}'`) - return { - byGrade: [], - byDiscipline: disciplines, - byGradeBand: { - ...INIT_GRADEBAND - } - } + return DEFAULT } const climbs = crag.climbs as ClimbType[] diff --git a/src/db/utils/jobs/CragGeojson/index.ts b/src/db/utils/jobs/CragGeojson/index.ts index b0c91bfe..24f5f9df 100644 --- a/src/db/utils/jobs/CragGeojson/index.ts +++ b/src/db/utils/jobs/CragGeojson/index.ts @@ -16,21 +16,26 @@ async function exportLeafCrags (): Promise { const features: Array> = [] - for await (const doc of model.find({ 'metadata.leaf': true }).lean()) { - const { metadata, area_name: areaName, pathTokens, ancestors } = doc + for await (const doc of model.find({ 'metadata.leaf': true, 'metadata.lnglat': { $ne: null } }).lean()) { + if (doc.metadata.lnglat == null) { + continue + } + + const { metadata, area_name: areaName, pathTokens, ancestors, content } = doc const ancestorArray = ancestors.split(',') const pointFeature = point(doc.metadata.lnglat.coordinates, { - id: metadata.area_id.toUUID().toString(), name: areaName, type: 'crag', + content, parent: { id: ancestorArray[ancestorArray.length - 2], name: pathTokens[doc.pathTokens.length - 2] } + }, { + id: metadata.area_id.toUUID().toString() }) features.push(pointFeature) } @@ -112,16 +117,16 @@ async function exportCragGroups (): Promise { const features: Array> = [] for await (const doc of rs) { const polygonFeature = feature(doc.polygon, { type: 'crag-group', name: doc.name, - id: doc.uuid.toUUID().toString(), children: doc.childAreaList.map(({ uuid, name, leftRightIndex }) => ( { id: uuid.toUUID().toString(), name, lr: leftRightIndex })) + }, { + id: doc.uuid.toUUID().toString() }) features.push(polygonFeature) } diff --git a/src/db/utils/jobs/CragUpdater.ts b/src/db/utils/jobs/CragUpdater.ts index 41590e14..a1d35d9b 100644 --- a/src/db/utils/jobs/CragUpdater.ts +++ b/src/db/utils/jobs/CragUpdater.ts @@ -1,4 +1,6 @@ import mongoose from 'mongoose' +import bbox2Polygon from '@turf/bbox-polygon' + import { getAreaModel } from '../../AreaSchema.js' import { getClimbModel } from '../../ClimbSchema.js' import { AreaType } from '../../AreaTypes.js' @@ -17,7 +19,12 @@ export const visitAllCrags = async (): Promise => { // Get all crags const iterator = areaModel - .find({ 'metadata.leaf': true }).batchSize(10) + .find({ + $or: [ + { 'metadata.leaf': true }, + { children: { $exists: true, $size: 0 } } + ] + }).batchSize(10) .populate<{ climbs: ClimbType[] }>({ path: 'climbs', model: getClimbModel() }) .allowDiskUse(true) @@ -26,7 +33,9 @@ export const visitAllCrags = async (): Promise => { for await (const crag of iterator) { const node: AreaMongoType = crag node.aggregate = aggregateCragStats(crag.toObject()) - node.metadata.bbox = bboxFrom(node.metadata.lnglat) + const bbox = bboxFrom(node.metadata.lnglat) + node.metadata.bbox = bbox + node.metadata.polygon = bbox == null ? undefined : bbox2Polygon(bbox).geometry await node.save() } } diff --git a/src/db/utils/jobs/TreeUpdater.ts b/src/db/utils/jobs/TreeUpdater.ts index 5f41ade0..e199f658 100644 --- a/src/db/utils/jobs/TreeUpdater.ts +++ b/src/db/utils/jobs/TreeUpdater.ts @@ -1,12 +1,13 @@ import mongoose from 'mongoose' -import { featureCollection, BBox, Point, Polygon } from '@turf/helpers' +import { featureCollection, BBox, Point, Polygon, Feature } from '@turf/helpers' import bbox2Polygon from '@turf/bbox-polygon' +import bboxFromGeojson from '@turf/bbox' import convexHull from '@turf/convex' import pLimit from 'p-limit' import { getAreaModel } from '../../AreaSchema.js' import { AreaType, AggregateType } from '../../AreaTypes.js' -import { bboxFromList, areaDensity } from '../../../geo-utils.js' +import { areaDensity } from '../../../geo-utils.js' import { mergeAggregates } from '../Aggregate.js' const limiter = pLimit(1000) @@ -59,14 +60,14 @@ export const visitAllAreas = async (): Promise => { interface ResultType { density: number totalClimbs: number - bbox: BBox - lnglat: Point + bbox?: BBox + lnglat?: Point aggregate: AggregateType polygon?: Polygon } async function postOrderVisit (node: AreaMongoType): Promise { - if (node.metadata.leaf) { + if (node.metadata.leaf || node.children.length === 0) { return leafReducer((node.toObject() as AreaType)) } @@ -95,6 +96,7 @@ const leafReducer = (node: AreaType): ResultType => { totalClimbs: node.totalClimbs, bbox: node.metadata.bbox, lnglat: node.metadata.lnglat, + polygon: node.metadata.polygon, density: node.density, aggregate: node.aggregate ?? { byGrade: [], @@ -113,12 +115,16 @@ const leafReducer = (node: AreaType): ResultType => { /** * Calculate convex hull polyon contain all child areas */ -const calculatePolygonFromChildren = (nodes: ResultType[]): Polygon | undefined => { - const childAsPolygons = nodes.map(node => bbox2Polygon(node.bbox)) +const calculatePolygonFromChildren = (nodes: ResultType[]): Feature | null => { + const childAsPolygons = nodes.reduce>>((acc, curr) => { + if (curr.bbox != null) { + acc.push(bbox2Polygon(curr.bbox)) + } + return acc + }, []) const fc = featureCollection(childAsPolygons) const polygonFeature = convexHull(fc) - - return polygonFeature?.geometry + return polygonFeature } /** @@ -130,11 +136,8 @@ const calculatePolygonFromChildren = (nodes: ResultType[]): Polygon | undefined const nodesReducer = async (result: ResultType[], parent: AreaMongoType): Promise => { const initial: ResultType = { totalClimbs: 0, - bbox: [-180, -90, 180, 90], - lnglat: { - type: 'Point', - coordinates: [0, 0] - }, + bbox: undefined, + lnglat: undefined, polygon: undefined, density: 0, aggregate: { @@ -149,30 +152,40 @@ const nodesReducer = async (result: ResultType[], parent: AreaMongoType): Promis } } } - - const z = result.reduce((acc, curr, index) => { - const { totalClimbs, bbox: _bbox, aggregate, lnglat } = curr - const bbox = index === 0 ? _bbox : bboxFromList([_bbox, acc.bbox]) - return { - totalClimbs: acc.totalClimbs + totalClimbs, - bbox, - lnglat, // we'll calculate a new center point later - density: -1, - polygon: undefined, - aggregate: mergeAggregates(acc.aggregate, aggregate) - } - }, initial) - - z.polygon = calculatePolygonFromChildren(result) - z.density = areaDensity(z.bbox, z.totalClimbs) - - const { totalClimbs, bbox, density, aggregate } = z - - parent.totalClimbs = totalClimbs - parent.metadata.bbox = bbox - parent.density = density - parent.aggregate = aggregate - parent.metadata.polygon = z.polygon - await parent.save() - return z + let nodeSummary: ResultType = initial + if (result.length === 0) { + const { totalClimbs, aggregate, density } = initial + parent.totalClimbs = totalClimbs + parent.density = density + parent.aggregate = aggregate + await parent.save() + return initial + } else { + nodeSummary = result.reduce((acc, curr) => { + const { totalClimbs, aggregate, lnglat, bbox } = curr + return { + totalClimbs: acc.totalClimbs + totalClimbs, + bbox, + lnglat, + density: -1, + polygon: undefined, + aggregate: mergeAggregates(acc.aggregate, aggregate) + } + }, initial) + + const polygon = calculatePolygonFromChildren(result) + nodeSummary.polygon = polygon?.geometry + nodeSummary.bbox = bboxFromGeojson(polygon) + nodeSummary.density = areaDensity(nodeSummary.bbox, nodeSummary.totalClimbs) + + const { totalClimbs, bbox, density, aggregate } = nodeSummary + + parent.totalClimbs = totalClimbs + parent.metadata.bbox = bbox + parent.density = density + parent.aggregate = aggregate + parent.metadata.polygon = nodeSummary.polygon + await parent.save() + return nodeSummary + } } diff --git a/src/geo-utils.ts b/src/geo-utils.ts index d781951a..62a2fd64 100644 --- a/src/geo-utils.ts +++ b/src/geo-utils.ts @@ -10,7 +10,8 @@ import { BBoxType } from './types' * @param point * @returns */ -export const bboxFrom = (point: Point): BBoxType => { +export const bboxFrom = (point: Point | undefined): BBoxType | undefined => { + if (point == null) return undefined const options = { steps: 8 } const r = 0.05 // unit=km. Hopefully this is a large enough area (but not too large) for a crag const cir = circle(point, r, options) @@ -33,7 +34,8 @@ export const bboxFromList = (bboxList: BBoxType[]): any => { * @param totalClimbs * @returns total climbs per km sq */ -export const areaDensity = (bbox: BBoxType, totalClimbs: number): number => { +export const areaDensity = (bbox: BBoxType | undefined, totalClimbs: number): number => { + if (bbox == null) return 0 const areaInKm = area(bboxPolygon(bbox)) / 1000000 const minArea = areaInKm < 5 ? 5 : areaInKm return totalClimbs / minArea diff --git a/src/graphql/schema/Area.gql b/src/graphql/schema/Area.gql index 6118ef45..0add644e 100644 --- a/src/graphql/schema/Area.gql +++ b/src/graphql/schema/Area.gql @@ -88,11 +88,11 @@ type AreaMetadata { isBoulder: Boolean "centroid latitude of this areas bounding box" - lat: Float! + lat: Float "centroid longitude of this areas bounding box" - lng: Float! + lng: Float "NE and SW corners of the bounding box for this area" - bbox: [Float]! + bbox: [Float] "Left-to-right sorting index. Undefined or -1 or unsorted area." leftRightIndex: Int diff --git a/src/model/MutableAreaDataSource.ts b/src/model/MutableAreaDataSource.ts index e8d9d8b3..f5b72ed7 100644 --- a/src/model/MutableAreaDataSource.ts +++ b/src/model/MutableAreaDataSource.ts @@ -1,4 +1,4 @@ -import { geometry, Point } from '@turf/helpers' +import { geometry } from '@turf/helpers' import muuid, { MUUID } from 'uuid-mongodb' import { v5 as uuidv5, NIL } from 'uuid' import mongoose, { ClientSession } from 'mongoose' @@ -499,8 +499,6 @@ export const newAreaHelper = (areaName: string, parentAncestors: string, parentP isDestination: false, leaf: false, area_id: uuid, - lnglat: geometry('Point', [0, 0]) as Point, - bbox: [-180, -90, 180, 90], leftRightIndex: -1, ext_id: '' }, diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 2af4a6e8..a6cc09e7 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -19,8 +19,8 @@ export function exhaustiveCheck (_value: never): never { throw new Error(`ERROR! Enum not handled for ${JSON.stringify(_value)}`) } -export const geojsonPointToLongitude = (point: Point): number => point.coordinates[0] -export const geojsonPointToLatitude = (point: Point): number => point.coordinates[1] +export const geojsonPointToLongitude = (point?: Point | undefined): number | undefined => point?.coordinates[0] +export const geojsonPointToLatitude = (point?: Point): number | undefined => point?.coordinates[1] export const NON_ALPHANUMERIC_REGEX = /[\W_\s]+/g export const canonicalizeUsername = (username: string): string => username.replaceAll(NON_ALPHANUMERIC_REGEX, '')