import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js"; import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js"; export const YARD_SCHEMA_VERSION = 3; class YardFeature { constructor(kind, geometryType, name, details = {}) { const featureType = getFeatureType(kind); if (featureType.geometryType !== geometryType) { throw new Error( `${kind} features must use ${featureType.geometryType} geometry, got ${geometryType}`, ); } this.kind = kind; this.geometryType = geometryType; this.name = name ?? featureType.label; this.id = buildFeatureId(kind, this.name); const { plantedDate = null, removedDate = null, ...restDetails } = details; this.plantedDate = plantedDate; this.removedDate = removedDate; this.details = { ...restDetails }; this.coordinates = []; this.parentId = null; this.groupIds = []; } add(...values) { this.coordinates.push(resolveCoordinate(values)); return this; } trace(coordinates) { for (const coordinate of pointsFromLngLat(coordinates)) { this.add(coordinate); } return this; } withDetails(details) { Object.assign(this.details, details); return this; } withId(id) { this.id = id; return this; } plantedOn(plantedDate) { this.plantedDate = plantedDate; return this; } removedOn(removedDate) { this.removedDate = removedDate; return this; } withLifecycleDates(plantedDate, removedDate = null) { this.plantedDate = plantedDate; this.removedDate = removedDate; return this; } childOf(parent) { this.parentId = resolveFeatureReference(parent); return this; } inGroup(...groups) { this.groupIds.push(...groups.map(resolveGroupReference)); this.groupIds = [...new Set(this.groupIds)]; return this; } toGeoJSON() { const label = getFeatureType(this.kind).label; const properties = { id: this.id, kind: this.kind, label, name: this.name, plantedDate: this.plantedDate, removedDate: this.removedDate, parentId: this.parentId, groupIds: this.groupIds, details: this.details, }; if (this.geometryType === "Point") { const [first] = this.coordinates; return { type: "Feature", properties, geometry: { type: "Point", coordinates: [first.lon, first.lat], }, }; } const coordinates = this.coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]); const geometry = this.geometryType === "LineString" ? { type: "LineString", coordinates } : { type: "Polygon", coordinates: [closeRing(coordinates)] }; return { type: "Feature", properties, geometry, }; } } function buildFeatureId(kind, name) { return `${kind}:${slugify(name)}`; } function slugify(value) { return String(value) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/^-+|-+$/g, "") .replaceAll(/-{2,}/g, "-"); } function resolveFeatureReference(featureOrId) { if (typeof featureOrId === "string") { return featureOrId; } if (featureOrId && typeof featureOrId.id === "string") { return featureOrId.id; } throw new Error("Expected a feature or feature id"); } function resolveGroupReference(groupOrId) { if (typeof groupOrId === "string") { return groupOrId; } if (groupOrId && typeof groupOrId.id === "string") { return groupOrId.id; } throw new Error("Expected a group or group id"); } export function defineGroup(id, label, options = {}) { return { id, label, parentGroupId: options.parentGroupId ?? null, }; } class PolygonFeature extends YardFeature { constructor(kind, name, details) { super(kind, "Polygon", name, details); } } class LineFeature extends YardFeature { constructor(kind, name, details) { super(kind, "LineString", name, details); } } class PointFeature extends YardFeature { constructor(kind, name, details) { super(kind, "Point", name, details); } } function closeRing(coordinates) { if (coordinates.length === 0) { return coordinates; } const [firstLon, firstLat] = coordinates[0]; const [lastLon, lastLat] = coordinates[coordinates.length - 1]; if (firstLon === lastLon && firstLat === lastLat) { return coordinates; } return [...coordinates, coordinates[0]]; } function isPoint(value) { return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number"; } function isMove(value) { return ( Boolean(value) && typeof value.northMeters === "number" && typeof value.eastMeters === "number" ); } function isOffsetAnchor(value) { return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin); } function resolveCoordinate(values) { if (values.length === 1 && isPoint(values[0])) { return values[0]; } const [first, ...rest] = values; if (isOffsetAnchor(first) && rest.every(isMove)) { if (rest.length === 0) { return first.origin; } return offset(first.origin, ...rest); } throw new Error("add(...) expects a point, or offset(origin) followed by directional moves"); } export class LotBoundary extends PolygonFeature { constructor(name = "Lot Boundary", details = {}) { super("lotBoundary", name, details); } } export class Fence extends LineFeature { constructor(name = "Fence", details = {}) { super("fence", name, details); } } export class YardGridLine extends LineFeature { constructor(name = "Yard Grid", details = {}) { super("yardGrid", name, details); } } export class House extends PolygonFeature { constructor(name = "House", details = {}) { super("house", name, details); } } export class Garage extends PolygonFeature { constructor(name = "Garage", details = {}) { super("garage", name, details); } } export class Porch extends PolygonFeature { constructor(name = "Porch", details = {}) { super("porch", name, details); } } export class Deck extends PolygonFeature { constructor(name = "Deck", details = {}) { super("deck", name, details); } } export class Walkway extends PolygonFeature { constructor(name = "Walkway", details = {}) { super("walkway", name, details); } } export class Driveway extends PolygonFeature { constructor(name = "Driveway", details = {}) { super("driveway", name, details); } } export class FlowerBed extends PolygonFeature { constructor(name = "Flower Bed", details = {}) { super("flowerBed", name, details); } } export class Plant extends PointFeature { constructor(name = "Plant", details = {}, kind = "plant") { super(kind, name, normalizePlantDetails(details)); } } export class Flower extends Plant { constructor(name = "Flower", details = {}, kind = "flower") { super(name, details, kind); } } export class Daylily extends Flower { constructor(name = "Daylily", details = {}) { super(name, { commonName: "Daylily", genus: "Hemerocallis", ...details, }, "daylily"); } } export class Shrub extends Plant { constructor(name = "Shrub", details = {}) { super(name, details, "shrub"); } } export class Bush extends Shrub { constructor(name = "Bush", details = {}) { super(name, details); } } export class Tree extends Plant { constructor(name = "Tree", details = {}) { super(name, details, "tree"); } } function normalizePlantDetails(details) { const { canopyDiameter, height, measurements = [], ...restDetails } = details; const normalizedMeasurements = normalizePlantMeasurements(measurements); if (!normalizedMeasurements.length && hasPlantSizeMeasurement(details)) { normalizedMeasurements.push( normalizePlantMeasurement({ canopyDiameter, height, }), ); } const latestMeasurement = normalizedMeasurements.at(-1); return { ...restDetails, ...(normalizedMeasurements.length ? { measurements: normalizedMeasurements } : {}), ...(latestMeasurement?.canopyDiameter !== undefined ? { canopyDiameter: latestMeasurement.canopyDiameter } : {}), ...(latestMeasurement?.height !== undefined ? { height: latestMeasurement.height } : {}), }; } function normalizePlantMeasurements(measurements) { if (!Array.isArray(measurements)) { throw new Error("Plant measurements must be an array"); } return measurements.map(normalizePlantMeasurement); } function normalizePlantMeasurement(measurement) { if (!measurement || typeof measurement !== "object") { throw new Error("Plant measurement entries must be objects"); } const { canopyDiameter, height, measuredDate, ...restMeasurement } = measurement; return { ...restMeasurement, ...(measuredDate ? { measuredDate } : {}), ...(canopyDiameter !== undefined ? { canopyDiameter } : {}), ...(height !== undefined ? { height } : {}), }; } function hasPlantSizeMeasurement(details) { return details.canopyDiameter !== undefined || details.height !== undefined; } export class Sprinkler extends PointFeature { constructor(name = "Sprinkler", details = {}) { super("sprinkler", name, details); } } export function collectYardData(features, groups = []) { const collection = collectGeoJSON(features); collection.schemaVersion = YARD_SCHEMA_VERSION; collection.lifecycleFields = { plantedDate: "date", removedDate: "date", }; collection.measurementFields = { measuredDate: "date", canopyDiameter: "number", height: "number", }; collection.featureTypes = listFeatureTypeSchemas(); collection.groups = groups.map((group) => ({ id: group.id, label: group.label, parentGroupId: group.parentGroupId ?? null, })); return collection; } export { collectGeoJSON };