414 lines
9.7 KiB
JavaScript
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 };
|