Many improvements
This commit is contained in:
@@ -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
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
export const lotAssumedNorthClockwiseDegrees = -11.25369719051117;
|
||||
+4
-1
@@ -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,
|
||||
],
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: '© <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(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user