Files
a-plot-of-plants/src/lib/yard-features.js
T

414 lines
9.7 KiB
JavaScript

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 };