Prototype

This commit is contained in:
2026-06-26 11:23:09 -05:00
commit 2a52e0546e
19 changed files with 1406 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js";
import { getFeatureType, resolveFeatureStyle } from "./feature-types.js";
class YardFeature {
constructor(kind, geometryType, name, details = {}) {
this.kind = kind;
this.geometryType = geometryType;
this.name = name ?? getFeatureType(kind).label;
this.id = buildFeatureId(kind, this.name);
this.details = { ...details };
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;
}
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 style = resolveFeatureStyle(this.kind, this.details);
if (this.geometryType === "Point") {
const [first] = this.coordinates;
return {
type: "Feature",
properties: {
id: this.id,
kind: this.kind,
label,
name: this.name,
parentId: this.parentId,
groupIds: this.groupIds,
details: this.details,
style,
},
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: {
id: this.id,
kind: this.kind,
label,
name: this.name,
parentId: this.parentId,
groupIds: this.groupIds,
details: this.details,
style,
},
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)) {
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 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 Tree extends PointFeature {
constructor(name = "Tree", details = {}) {
super("tree", name, details);
}
}
export class Sprinkler extends PointFeature {
constructor(name = "Sprinkler", details = {}) {
super("sprinkler", name, details);
}
}
export function collectYardData(features, groups = []) {
const collection = collectGeoJSON(features);
collection.groups = groups.map((group) => ({
id: group.id,
label: group.label,
parentGroupId: group.parentGroupId ?? null,
}));
return collection;
}
export { collectGeoJSON };