Many improvements

This commit is contained in:
2026-06-26 15:48:12 -05:00
parent 2a52e0546e
commit c5725a0259
22 changed files with 1392 additions and 97 deletions
+45
View File
@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
<title id="title">Daylily</title>
<desc id="desc">Abstract daylily clump with spindly fronds and flower stalks.</desc>
<defs>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1.5" stdDeviation="1.2" flood-color="#1d2d16" flood-opacity="0.28" />
</filter>
</defs>
<g fill="none" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)">
<g stroke="#315f2b">
<path d="M32 58 C24 47 16 40 8 34" stroke-width="3.1" />
<path d="M32 58 C25 45 22 35 20 22" stroke-width="2.7" />
<path d="M32 58 C30 43 31 29 35 14" stroke-width="2.7" />
<path d="M32 58 C40 45 47 37 56 30" stroke-width="3.1" />
<path d="M32 58 C38 48 42 40 44 27" stroke-width="2.5" />
</g>
<g stroke="#5f8d39">
<path d="M32 58 C21 51 13 48 5 48" stroke-width="2.4" />
<path d="M32 58 C24 49 19 44 13 39" stroke-width="2.2" />
<path d="M32 58 C29 47 28 38 29 27" stroke-width="2.2" />
<path d="M32 58 C35 47 37 37 40 26" stroke-width="2.2" />
<path d="M32 58 C42 50 50 46 60 44" stroke-width="2.4" />
<path d="M32 58 C43 53 51 52 58 55" stroke-width="2.1" />
</g>
<g stroke="#6f7f32">
<path d="M32 57 C33 43 34 30 35 16" stroke-width="1.7" />
<path d="M31 57 C28 43 27 31 25 18" stroke-width="1.5" />
<path d="M33 57 C42 45 47 34 50 22" stroke-width="1.5" />
</g>
<g fill="#f0b44c" stroke="#9f5e1d" stroke-width="1.4">
<path d="M35 15 C30 13 29 8 32 4 C36 7 38 11 35 15" />
<path d="M35 15 C39 11 44 12 47 16 C42 18 38 18 35 15" />
<path d="M35 15 C39 18 39 23 36 27 C33 23 32 19 35 15" />
<circle cx="35" cy="15" r="1.7" fill="#743719" stroke="none" />
<path d="M25 18 C21 15 21 11 24 8 C27 11 28 15 25 18" />
<path d="M25 18 C29 16 32 18 33 22 C29 23 26 21 25 18" />
<path d="M25 18 C23 22 19 23 16 21 C18 17 21 16 25 18" />
<circle cx="25" cy="18" r="1.5" fill="#743719" stroke="none" />
<path d="M50 22 C46 19 47 15 50 12 C53 15 54 19 50 22" />
<path d="M50 22 C54 20 58 22 59 26 C55 27 52 25 50 22" />
<path d="M50 22 C49 26 45 28 42 26 C44 23 47 21 50 22" />
<circle cx="50" cy="22" r="1.4" fill="#743719" stroke="none" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+2 -3
View File
@@ -1,7 +1,6 @@
import { anchor } from "../lib/geometry.js";
import { lotAssumedNorthClockwiseDegrees } from "./settings.js";
export const topLeftLotCorner = anchor(-100.89895832874616, 46.82679973173392, {
assumedNorthClockwiseDegrees: lotAssumedNorthClockwiseDegrees,
export const topLeftLotCorner = anchor(-100.898958, 46.826799, {
assumedNorthClockwiseDegrees: -11.25,
});
export const lotCorner = topLeftLotCorner;
+7 -6
View File
@@ -1,11 +1,12 @@
import * as geo from "../lib/geometry.js";
import { Fence, LotBoundary } from "../lib/yard-features.js";
import { lotCorner } from "./anchors.js";
const lotBoundary = new LotBoundary().trace([
[-100.89895832874616, 46.82679973173392],
[-100.89839320971627, 46.8268766687031],
[-100.89835104203355, 46.82673956300183],
[-100.89891868391628, 46.82666533836659],
]);
const lotBoundary = new LotBoundary()
.add(geo.offset(lotCorner))
.add(geo.offset(lotCorner), geo.east(geo.ft(144)))
.add(geo.offset(lotCorner), geo.east(geo.ft(144)), geo.south(geo.ft(51)))
.add(geo.offset(lotCorner), geo.south(geo.ft(51)));
const picketFence = new Fence("Short picket fence", {
fenceType: "Short picket",
+57
View File
@@ -0,0 +1,57 @@
import * as geo from "../lib/geometry.js";
import { defineGroup, YardGridLine } from "../lib/yard-features.js";
import { lotCorner } from "./anchors.js";
const GRID_SPACING_FEET = 10;
const MIN_EAST_FEET = -20;
const MAX_EAST_FEET = 180;
const MIN_NORTH_FEET = -140;
const MAX_NORTH_FEET = 20;
export const yardGridGroup = defineGroup("group:yard-grid", "Yard Grid");
function localPoint(eastFeet, northFeet) {
return geo.offset(
lotCorner,
geo.east(geo.ft(eastFeet)),
northFeet >= 0 ? geo.north(geo.ft(northFeet)) : geo.south(geo.ft(Math.abs(northFeet))),
);
}
function makeNorthLine(eastFeet) {
return new YardGridLine(`Grid east ${eastFeet} ft`, {
axis: "north",
offsetFeet: eastFeet,
spacingFeet: GRID_SPACING_FEET,
})
.inGroup(yardGridGroup)
.add(localPoint(eastFeet, MIN_NORTH_FEET))
.add(localPoint(eastFeet, MAX_NORTH_FEET));
}
function makeEastLine(northFeet) {
return new YardGridLine(`Grid north ${northFeet} ft`, {
axis: "east",
offsetFeet: northFeet,
spacingFeet: GRID_SPACING_FEET,
})
.inGroup(yardGridGroup)
.add(localPoint(MIN_EAST_FEET, northFeet))
.add(localPoint(MAX_EAST_FEET, northFeet));
}
function rangeByFeet(minFeet, maxFeet, spacingFeet) {
const values = [];
for (let value = minFeet; value <= maxFeet; value += spacingFeet) {
values.push(value);
}
return values;
}
const northLines = rangeByFeet(MIN_EAST_FEET, MAX_EAST_FEET, GRID_SPACING_FEET).map(makeNorthLine);
const eastLines = rangeByFeet(MIN_NORTH_FEET, MAX_NORTH_FEET, GRID_SPACING_FEET).map(makeEastLine);
export const gridFeatures = [...northLines, ...eastLines];
export const gridGroups = [yardGridGroup];
+30 -15
View File
@@ -1,6 +1,6 @@
import { defineGroup, FlowerBed, Tree } from "../lib/yard-features.js";
import { east, ft, lngLat, offset } from "../lib/geometry.js";
import { topLeftLotCorner } from "./anchors.js";
import { Daylily, defineGroup, FlowerBed, Tree } from "../lib/yard-features.js";
import * as geo from "../lib/geometry.js";
import { lotCorner } from "./anchors.js";
export const juniperTreesGroup = defineGroup("group:juniper-trees", "Juniper Trees");
@@ -47,60 +47,75 @@ const flowerBeds = [
]),
];
const daylilies = [
new Daylily("You Had Me At Merlot", {
daylilyDatabaseId: "194128",
daylilyDatabaseUrl: "https://daylilydatabase.org/detail.php?id=194128",
}).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(45))),
new Daylily("Mapping North Dakota", {
daylilyDatabaseId: "158640",
daylilyDatabaseUrl: "https://daylilydatabase.org/detail.php?id=158640",
}).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(47))),
new Daylily("Daylily", {
daylilyDatabaseId: "",
daylilyDatabaseUrl: "",
}).add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))),
];
const trees = [
new Tree("Japanese Empress Elm", {
species: "Japanese Empress Elm",
canopyDiameterMeters: 6,
heightMeters: 7,
}).add(lngLat(-100.89889018162828, 46.82675624026659)),
}).add(geo.offset(lotCorner), geo.east(geo.ft(9)), geo.south(geo.ft(16))),
new Tree("Boulevard Linden", {
species: "Boulevard Linden",
canopyDiameterMeters: 3,
heightMeters: 6,
plantedDate: "2021-06-08",
}).add(lngLat(-100.8990003556445, 46.826776648309774)),
}).add(geo.lngLat(-100.8990003556445, 46.826776648309774)),
new Tree("Boulevard Linden 2", {
species: "Boulevard Linden",
canopyDiameterMeters: 3,
heightMeters: 6,
}).add(lngLat(-100.89898269567485, 46.826709328342595)),
}).add(geo.lngLat(-100.89898269567485, 46.826709328342595)),
new Tree("Evergreen", {
species: "Evergreen",
canopyDiameterMeters: 8,
heightMeters: 15,
}).add(lngLat(-100.89888268258135, 46.826680107011924)),
}).add(geo.lngLat(-100.89888268258135, 46.826680107011924)),
new Tree("Medora Juniper 1", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.89880933964611, 46.826705259550664)),
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89880933964611, 46.826705259550664)),
new Tree("Medora Juniper 2", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.89877004677517, 46.82670396593881)),
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89877004677517, 46.82670396593881)),
new Tree("Medora Juniper 3", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.898799106227, 46.82669830398086)),
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.898799106227, 46.82669830398086)),
new Tree("Medora Juniper 4", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.89878871410515, 46.82669251034885)),
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89878871410515, 46.82669251034885)),
new Tree("Medora Juniper 5", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.8987812086838, 46.82671120797719)),
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)),
new Tree("Offset Test Tree", {
species: "Test tree",
canopyDiameterMeters: 4,
heightMeters: 5,
note: "70 feet east of top-left lot corner using assumedNorth",
}).add(offset(topLeftLotCorner), east(ft(70))),
}).add(geo.offset(lotCorner), geo.east(geo.ft(70))),
];
export const plantFeatures = [...flowerBeds, ...trees];
export const plantGroups = [juniperTreesGroup];
export const plantFeatures = [...flowerBeds, ...daylilies, ...trees];
export const plantGroups = [juniperTreesGroup];
-1
View File
@@ -1 +0,0 @@
export const lotAssumedNorthClockwiseDegrees = -11.25369719051117;
+4 -1
View File
@@ -1,6 +1,7 @@
import { collectYardData } from "../lib/yard-features.js";
import { boundaryFeatures } from "./boundaries.js";
import { buildingFeatures, buildingGroups } from "./buildings.js";
import { gridFeatures, gridGroups } from "./grid.js";
import { irrigationFeatures } from "./irrigation.js";
import { pavementFeatures } from "./pavement.js";
import { plantFeatures, plantGroups } from "./plants.js";
@@ -8,6 +9,7 @@ import { plantFeatures, plantGroups } from "./plants.js";
export const yardGeoJSON = collectYardData(
[
...boundaryFeatures,
...gridFeatures,
...buildingFeatures,
...pavementFeatures,
...plantFeatures,
@@ -15,6 +17,7 @@ export const yardGeoJSON = collectYardData(
],
[
...buildingGroups,
...gridGroups,
...plantGroups,
],
);
);
+82
View File
@@ -0,0 +1,82 @@
const overlay = document.createElement("section");
overlay.className = "error-overlay";
overlay.hidden = true;
overlay.innerHTML = `
<div class="error-overlay-header">
<strong>JavaScript error</strong>
<div class="error-overlay-actions">
<button type="button" data-action="copy">Copy</button>
<button type="button" data-action="dismiss" aria-label="Dismiss error">Dismiss</button>
</div>
</div>
<pre></pre>
`;
const messageNode = overlay.querySelector("pre");
const copyButton = overlay.querySelector("[data-action='copy']");
const dismissButton = overlay.querySelector("[data-action='dismiss']");
copyButton.addEventListener("click", async () => {
await copyText(messageNode.textContent);
showCopyStatus();
});
dismissButton.addEventListener("click", () => {
overlay.hidden = true;
});
document.addEventListener("DOMContentLoaded", () => {
document.body.append(overlay);
});
window.addEventListener("error", (event) => {
showError(formatErrorEvent(event));
});
window.addEventListener("unhandledrejection", (event) => {
showError(formatReason(event.reason));
});
function showError(message) {
messageNode.textContent = message;
copyButton.textContent = "Copy";
overlay.hidden = false;
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.setAttribute("readonly", "");
textArea.className = "error-overlay-copy-buffer";
document.body.append(textArea);
textArea.select();
document.execCommand("copy");
textArea.remove();
}
function showCopyStatus() {
copyButton.textContent = "Copied";
window.setTimeout(() => {
copyButton.textContent = "Copy";
}, 1400);
}
function formatErrorEvent(event) {
const location = [event.filename, event.lineno, event.colno].filter(Boolean).join(":");
const detail = formatReason(event.error ?? event.message);
return location ? `${detail}\n\n${location}` : detail;
}
function formatReason(reason) {
if (reason instanceof Error) {
return reason.stack ?? `${reason.name}: ${reason.message}`;
}
return String(reason);
}
+180 -14
View File
@@ -1,15 +1,8 @@
const treeRadiusRange = {
min: 5,
max: 14,
};
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
export const featureTypes = {
lotBoundary: {
label: "Lot Boundary",
geometryType: "Polygon",
detailFields: {},
style: {
color: "#7b684d",
weight: 2,
@@ -20,14 +13,35 @@ export const featureTypes = {
},
fence: {
label: "Fence",
geometryType: "LineString",
detailFields: {
fenceType: "string",
},
style: {
color: "#705033",
weight: 4,
opacity: 0.95,
},
},
yardGrid: {
label: "Yard Grid",
geometryType: "LineString",
detailFields: {
axis: "string",
offsetFeet: "number",
spacingFeet: "number",
},
style: (details) => ({
color: details.offsetFeet === 0 ? "#1f4f63" : "#2f7f98",
weight: details.offsetFeet === 0 ? 3 : 2,
opacity: details.offsetFeet === 0 ? 0.75 : 0.55,
dashArray: details.offsetFeet === 0 ? undefined : "6 5",
}),
},
house: {
label: "House",
geometryType: "Polygon",
detailFields: {},
style: {
color: "#8d5a35",
weight: 2,
@@ -37,6 +51,8 @@ export const featureTypes = {
},
garage: {
label: "Garage",
geometryType: "Polygon",
detailFields: {},
style: {
color: "#60636b",
weight: 2,
@@ -46,6 +62,8 @@ export const featureTypes = {
},
porch: {
label: "Porch",
geometryType: "Polygon",
detailFields: {},
style: {
color: "#aa7148",
weight: 2,
@@ -55,6 +73,8 @@ export const featureTypes = {
},
deck: {
label: "Deck",
geometryType: "Polygon",
detailFields: {},
style: {
color: "#7f5a3c",
weight: 2,
@@ -64,6 +84,8 @@ export const featureTypes = {
},
walkway: {
label: "Walkway",
geometryType: "Polygon",
detailFields: {},
style: {
color: "#b8b3a6",
weight: 2,
@@ -73,6 +95,8 @@ export const featureTypes = {
},
driveway: {
label: "Driveway",
geometryType: "Polygon",
detailFields: {},
style: {
color: "#9c9890",
weight: 2,
@@ -82,6 +106,12 @@ export const featureTypes = {
},
flowerBed: {
label: "Flower Bed",
geometryType: "Polygon",
detailFields: {
soil: "string",
mulch: "string",
sunExposure: "string",
},
style: {
color: "#7f7f31",
weight: 2,
@@ -89,18 +119,130 @@ export const featureTypes = {
fillOpacity: 0.38,
},
},
plant: {
label: "Plant",
geometryType: "Point",
detailFields: {
commonName: "string",
genus: "string",
species: "string",
cultivar: "string",
plantedDate: "date",
note: "string",
},
style: {
color: "#3d7140",
weight: 2,
radius: 5,
fillColor: "#78a658",
fillOpacity: 0.8,
},
},
flower: {
label: "Flower",
parentKind: "plant",
geometryType: "Point",
detailFields: {
commonName: "string",
genus: "string",
species: "string",
cultivar: "string",
bloomColor: "string",
bloomSeason: "string",
plantedDate: "date",
note: "string",
},
style: {
color: "#9c3f74",
weight: 2,
radius: 5,
fillColor: "#d979aa",
fillOpacity: 0.85,
},
},
daylily: {
label: "Daylily",
parentKind: "flower",
geometryType: "Point",
detailFields: {
commonName: "string",
genus: "string",
cultivar: "string",
bloomColor: "string",
bloomSeason: "string",
daylilyDatabaseId: "string",
daylilyDatabaseUrl: "url",
plantedDate: "date",
note: "string",
},
externalLinks: [
{
label: "Daylily database",
url: (details) => details.daylilyDatabaseUrl ?? null,
},
],
style: {
color: "#9f5e1d",
weight: 2,
radius: 5,
fillColor: "#f0b44c",
fillOpacity: 0.88,
iconUrl: "./src/assets/icons/daylily.svg",
iconDiameterMeters: 2.5 / 3.280839895,
},
},
shrub: {
label: "Shrub",
parentKind: "plant",
geometryType: "Point",
detailFields: {
commonName: "string",
genus: "string",
species: "string",
cultivar: "string",
canopyDiameterMeters: "number",
heightMeters: "number",
plantedDate: "date",
note: "string",
},
style: (details) => ({
color: "#426a3d",
weight: 1,
radiusMeters: (details.canopyDiameterMeters ?? 1) / 2,
fillColor: "#7e9d58",
fillOpacity: 0.62,
}),
},
tree: {
label: "Tree",
parentKind: "plant",
geometryType: "Point",
detailFields: {
commonName: "string",
genus: "string",
species: "string",
cultivar: "string",
canopyDiameterMeters: "number",
heightMeters: "number",
plantedDate: "date",
note: "string",
},
style: (details) => ({
color: "#2f6b3d",
weight: 2,
radius: clamp(4 + (details.canopyDiameterMeters ?? 2), treeRadiusRange.min, treeRadiusRange.max),
weight: 1,
radiusMeters: (details.canopyDiameterMeters ?? 2) / 2,
fillColor: "#5c9e64",
fillOpacity: 0.85,
fillOpacity: 0.55,
}),
},
sprinkler: {
label: "Sprinkler",
geometryType: "Point",
detailFields: {
zone: "string",
headType: "string",
flowRateGallonsPerMinute: "number",
},
style: {
color: "#2d7dd2",
radius: 5,
@@ -119,7 +261,31 @@ export function getFeatureType(kind) {
return definition;
}
export function listFeatureTypeSchemas() {
return Object.entries(featureTypes).map(([kind, definition]) => ({
kind,
label: definition.label,
parentKind: definition.parentKind ?? null,
geometryType: definition.geometryType,
detailFields: { ...definition.detailFields },
externalLinks: (definition.externalLinks ?? []).map((link) => ({
label: link.label,
})),
}));
}
export function resolveFeatureStyle(kind, details = {}) {
const { style } = getFeatureType(kind);
return typeof style === "function" ? style(details) : style;
}
return typeof style === "function" ? style(details) : { ...style };
}
export function resolveFeatureLinks(kind, details = {}) {
const { externalLinks = [] } = getFeatureType(kind);
return externalLinks
.map((link) => ({
label: link.label,
url: link.url(details),
}))
.filter((link) => link.url);
}
+97 -27
View File
@@ -1,13 +1,25 @@
import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js";
import { getFeatureType, resolveFeatureStyle } from "./feature-types.js";
import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js";
export const YARD_SCHEMA_VERSION = 1;
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 ?? getFeatureType(kind).label;
this.name = name ?? featureType.label;
this.id = buildFeatureId(kind, this.name);
this.details = { ...details };
const { startDate = null, endDate = null, ...restDetails } = details;
this.startDate = startDate;
this.endDate = endDate;
this.details = { ...restDetails };
this.coordinates = [];
this.parentId = null;
this.groupIds = [];
@@ -36,6 +48,22 @@ class YardFeature {
return this;
}
activeFrom(startDate) {
this.startDate = startDate;
return this;
}
activeUntil(endDate) {
this.endDate = endDate;
return this;
}
withDateRange(startDate, endDate = null) {
this.startDate = startDate;
this.endDate = endDate;
return this;
}
childOf(parent) {
this.parentId = resolveFeatureReference(parent);
return this;
@@ -49,22 +77,23 @@ class YardFeature {
toGeoJSON() {
const label = getFeatureType(this.kind).label;
const style = resolveFeatureStyle(this.kind, this.details);
const properties = {
id: this.id,
kind: this.kind,
label,
name: this.name,
startDate: this.startDate,
endDate: this.endDate,
parentId: this.parentId,
groupIds: this.groupIds,
details: 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,
},
properties,
geometry: {
type: "Point",
coordinates: [first.lon, first.lat],
@@ -80,16 +109,7 @@ class YardFeature {
return {
type: "Feature",
properties: {
id: this.id,
kind: this.kind,
label,
name: this.name,
parentId: this.parentId,
groupIds: this.groupIds,
details: this.details,
style,
},
properties,
geometry,
};
}
@@ -194,6 +214,10 @@ function resolveCoordinate(values) {
const [first, ...rest] = values;
if (isOffsetAnchor(first) && rest.every(isMove)) {
if (rest.length === 0) {
return first.origin;
}
return offset(first.origin, ...rest);
}
@@ -212,6 +236,12 @@ export class Fence extends LineFeature {
}
}
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);
@@ -254,9 +284,43 @@ export class FlowerBed extends PolygonFeature {
}
}
export class Tree extends PointFeature {
export class Plant extends PointFeature {
constructor(name = "Plant", details = {}, kind = "plant") {
super(kind, name, 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("tree", name, details);
super(name, details, "tree");
}
}
@@ -268,6 +332,12 @@ export class Sprinkler extends PointFeature {
export function collectYardData(features, groups = []) {
const collection = collectGeoJSON(features);
collection.schemaVersion = YARD_SCHEMA_VERSION;
collection.lifecycleFields = {
startDate: "date",
endDate: "date",
};
collection.featureTypes = listFeatureTypeSchemas();
collection.groups = groups.map((group) => ({
id: group.id,
label: group.label,
@@ -276,4 +346,4 @@ export function collectYardData(features, groups = []) {
return collection;
}
export { collectGeoJSON };
export { collectGeoJSON };
+150 -13
View File
@@ -1,4 +1,5 @@
import { yardGeoJSON } from "./data/yard.js";
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
const TILE_MAX_NATIVE_ZOOM = 19;
const MAP_MAX_ZOOM = 24;
@@ -7,6 +8,7 @@ const map = L.map("map", {
zoomControl: false,
maxZoom: MAP_MAX_ZOOM,
});
const meterScaledIconMarkers = [];
L.control
.zoom({
@@ -20,26 +22,55 @@ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
const geoJsonLayer = L.geoJSON(yardGeoJSON, {
L.geoJSON(yardGeoJSON, {
pointToLayer(feature, latlng) {
const style = feature.properties.style;
return L.circleMarker(latlng, {
radius: style.radius ?? 6,
const style = getStyle(feature);
const options = {
color: style.color ?? "#333333",
fillColor: style.fillColor ?? style.color ?? "#333333",
fillOpacity: style.fillOpacity ?? 0.8,
weight: style.weight ?? 2,
};
if (style.iconUrl) {
const marker = L.marker(latlng, {
icon: buildIcon(style, latlng),
});
if (typeof style.iconDiameterMeters === "number") {
meterScaledIconMarkers.push({ marker, style, latlng });
}
return marker;
}
if (typeof style.radiusMeters === "number") {
return L.circle(latlng, {
...options,
radius: style.radiusMeters,
});
}
return L.circleMarker(latlng, {
...options,
radius: style.radius ?? 6,
});
},
style(feature) {
return feature.properties.style;
if (feature.geometry.type === "Point") {
return getPathStyle(feature);
}
return getStyle(feature);
},
onEachFeature(feature, layer) {
layer.bindPopup(buildPopupContent(feature.properties));
},
}).addTo(map);
map.fitBounds(geoJsonLayer.getBounds().pad(0.2), { maxZoom: MAP_MAX_ZOOM });
map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM });
updateMeterScaledIcons();
map.on("zoomend", updateMeterScaledIcons);
const legend = document.getElementById("legend");
const legendEntries = summarizeTypes(yardGeoJSON.features);
@@ -66,10 +97,11 @@ function summarizeTypes(features) {
for (const feature of features) {
const key = feature.properties.kind;
const style = getStyle(feature);
const existing = summary.get(key) ?? {
label: feature.properties.label,
count: 0,
color: feature.properties.style.color ?? "#333333",
color: style.color ?? "#333333",
};
existing.count += 1;
@@ -79,10 +111,96 @@ function summarizeTypes(features) {
return [...summary.values()];
}
function getStyle(feature) {
return resolveFeatureStyle(feature.properties.kind, feature.properties.details);
}
function getPathStyle(feature) {
const {
iconAnchor,
iconDiameterMeters,
iconSize,
iconUrl,
popupAnchor,
radius,
radiusMeters,
...style
} = getStyle(feature);
return style;
}
function buildIcon(style, latlng) {
const iconSize = getIconSize(style, latlng);
return L.icon({
iconUrl: style.iconUrl,
iconSize,
iconAnchor: [iconSize[0] / 2, iconSize[1] / 2],
popupAnchor: [0, -iconSize[1] / 2],
});
}
function updateMeterScaledIcons() {
for (const { marker, style, latlng } of meterScaledIconMarkers) {
marker.setIcon(buildIcon(style, latlng));
}
}
function getIconSize(style, latlng) {
if (typeof style.iconDiameterMeters !== "number") {
return style.iconSize ?? [24, 24];
}
if (typeof map.getZoom() !== "number") {
return style.iconSize ?? [24, 24];
}
const diameterPixels = style.iconDiameterMeters / getMetersPerPixel(latlng.lat);
return [diameterPixels, diameterPixels];
}
function getMetersPerPixel(lat) {
return (156543.03392 * Math.cos((lat * Math.PI) / 180)) / 2 ** map.getZoom();
}
function getGeoJSONBounds(collection) {
const bounds = L.latLngBounds();
for (const feature of collection.features) {
extendBounds(bounds, feature.geometry.coordinates);
}
return bounds;
}
function extendBounds(bounds, coordinates) {
if (typeof coordinates[0] === "number") {
const [lon, lat] = coordinates;
bounds.extend([lat, lon]);
return;
}
for (const coordinate of coordinates) {
extendBounds(bounds, coordinate);
}
}
function buildPopupContent(properties) {
const { label, name, details, parentId, groupIds } = properties;
const entries = Object.entries(details ?? {}).filter(([, value]) => value !== null && value !== undefined);
const { kind, label, name, startDate, endDate, details, parentId, groupIds } = properties;
const entries = Object.entries(details ?? {}).filter(
([key, value]) => value !== null && value !== undefined && !key.endsWith("Url"),
);
const lifecycleEntries = [];
const relationshipEntries = [];
const linkEntries = resolveFeatureLinks(kind, details);
if (startDate) {
lifecycleEntries.push(["startDate", startDate]);
}
if (endDate) {
lifecycleEntries.push(["endDate", endDate]);
}
if (parentId) {
relationshipEntries.push(["parentFeature", parentId]);
@@ -94,16 +212,19 @@ function buildPopupContent(properties) {
const title = `<strong>${escapeHtml(name)}</strong>`;
const subtitle = name === label ? "" : `<div>${escapeHtml(label)}</div>`;
const allEntries = [...entries, ...relationshipEntries];
if (allEntries.length === 0) {
const allEntries = [...lifecycleEntries, ...entries, ...relationshipEntries];
if (allEntries.length === 0 && linkEntries.length === 0) {
return `${title}${subtitle}`;
}
const detailRows = allEntries
.map(([key, value]) => `<div><span>${escapeHtml(formatDetailLabel(key))}:</span> ${escapeHtml(formatDetailValue(key, value))}</div>`)
.join("");
const linkRows = linkEntries
.map((link) => `<div><span>${escapeHtml(link.label)}:</span> <a href="${escapeHtml(link.url)}" target="_blank" rel="noreferrer">${escapeHtml(link.url)}</a></div>`)
.join("");
return `${title}${subtitle}<div>${detailRows}</div>`;
return `${title}${subtitle}<div>${detailRows}${linkRows}</div>`;
}
function formatDetailLabel(key) {
@@ -111,8 +232,20 @@ function formatDetailLabel(key) {
canopyDiameterMeters: "Canopy diameter",
heightMeters: "Height",
plantedDate: "Planted",
startDate: "Start",
endDate: "End",
commonName: "Common name",
genus: "Genus",
cultivar: "Cultivar",
bloomColor: "Bloom color",
bloomSeason: "Bloom season",
daylilyDatabaseId: "Daylily database ID",
daylilyDatabaseUrl: "Daylily database URL",
species: "Species",
fenceType: "Fence type",
axis: "Axis",
offsetFeet: "Offset",
spacingFeet: "Spacing",
note: "Note",
parentFeature: "Parent",
groups: "Groups",
@@ -126,6 +259,10 @@ function formatDetailValue(key, value) {
return `${value} m`;
}
if (key === "offsetFeet" || key === "spacingFeet") {
return `${value} ft`;
}
return String(value);
}
@@ -136,4 +273,4 @@ function escapeHtml(value) {
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
}