From 6fd308aca892e8ee97bb6bb37421c2e095b47b17 Mon Sep 17 00:00:00 2001
From: aaronaxvig
Date: Wed, 14 Oct 2026 21:23:38 -0500
Subject: [PATCH] Add printable yard map and sprinkler zones with estimated
coverage
---
README.md | 14 +++
index.html | 7 ++
src/assets/icons/sprinkler-mist.svg | 10 ++
src/assets/icons/sprinkler-rotator.svg | 8 ++
src/data/irrigation.js | 74 +++++++++++-
src/data/pavement.js | 33 +++++-
src/data/visibility-tree-manifest.json | 89 ++++++++++----
src/data/yard.js | 3 +-
src/lib/feature-types.js | 29 ++++-
src/lib/irrigation.js | 33 ++++++
src/lib/print-map.js | 154 +++++++++++++++++++++++++
src/lib/yard-features.js | 6 +
src/main.js | 99 +++++++++++++++-
styles.css | 87 ++++++++++++++
14 files changed, 609 insertions(+), 37 deletions(-)
create mode 100644 src/assets/icons/sprinkler-mist.svg
create mode 100644 src/assets/icons/sprinkler-rotator.svg
create mode 100644 src/lib/irrigation.js
create mode 100644 src/lib/print-map.js
diff --git a/README.md b/README.md
index a3292c3..2045e8c 100644
--- a/README.md
+++ b/README.md
@@ -124,3 +124,17 @@ of 24.
- Decide whether parent-child feature relationships should further affect the visibility hierarchy.
- If more data import is needed from GIS sources, prefer one-time extraction into JS-authored modules rather than making the app depend on runtime GIS tooling.
+
+### Sprinkler map
+
+`src/data/irrigation.js` contains the heads transcribed from the annotated printout,
+organized into visibility groups for zones 1–4. Positions, types, and coverage are
+initial estimates. Near the house, the head marked 2 is a zone 2 mist head;
+the adjacent head marked 4 is a zone 4 rotator. Each head has a stable zone/head ID.
+
+Head coordinates are authored in feet east and south of `lotCorner`. `reachFeet`
+is the spray radius. `startBearingDegrees` and `arcDegrees` describe a clockwise
+sector; the authoring helper converts lot-relative bearings to true bearings.
+Mist heads use a dotted fan symbol and dotted coverage; rotators use a sweep-arrow
+symbol and dashed coverage. Coverage and zone labels appear on screen and in print.
+Select a head to inspect its estimated dimensions and notes.
diff --git a/index.html b/index.html
index cf60299..f1e7382 100644
--- a/index.html
+++ b/index.html
@@ -18,6 +18,12 @@
a variety of trees, shrubs, and flowers. There are many varieties of
daylilies.
+ Print map
+ Prints selected features at the selected date, aligned with the lot.
+
+
Mist
+
Rotator
+
Map features
@@ -30,6 +36,7 @@
+
diff --git a/src/assets/icons/sprinkler-mist.svg b/src/assets/icons/sprinkler-mist.svg
new file mode 100644
index 0000000..6722851
--- /dev/null
+++ b/src/assets/icons/sprinkler-mist.svg
@@ -0,0 +1,10 @@
+
+ Mist sprinkler — continuous spray
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/sprinkler-rotator.svg b/src/assets/icons/sprinkler-rotator.svg
new file mode 100644
index 0000000..d16c73f
--- /dev/null
+++ b/src/assets/icons/sprinkler-rotator.svg
@@ -0,0 +1,8 @@
+
+ Rotator sprinkler — sweeping stream
+
+
+
+
+
+
diff --git a/src/data/irrigation.js b/src/data/irrigation.js
index 4c3e0d5..51de02a 100644
--- a/src/data/irrigation.js
+++ b/src/data/irrigation.js
@@ -1,8 +1,74 @@
import { east, ft, offset, south } from "../lib/geometry.js";
-import { Sprinkler } from "../lib/yard-features.js";
+import { defineGroup, Sprinkler } from "../lib/yard-features.js";
import { lotCorner } from "./anchors.js";
+export const irrigationGroup = defineGroup("group:irrigation", "Sprinklers", {
+ categoryId: "infrastructure",
+});
+const zones = [1, 2, 3, 4].map((zone) => defineGroup(
+ `group:irrigation-zone-${zone}`, `Zone ${zone}`, {
+ categoryId: "infrastructure", parentGroupId: irrigationGroup.id,
+ },
+));
+export const irrigationGroups = [irrigationGroup, ...zones];
+
+// Transcribed from the annotated October 14, 2026 printout using its 10 ft grid.
+// Locations are approximate. All reaches and arcs are initial visual estimates.
+// Coordinates: feet east/south of lotCorner; negative east is the boulevard.
+// Bearings below are clockwise from LOT north; coverage exports true bearings.
+function head(zone, number, name, eastFeet, southFeet, headType, reachFeet,
+ startBearingDegrees, arcDegrees, note) {
+ return new Sprinkler(`Zone ${zone} · ${number} · ${name}`, {
+ zone: String(zone), headType, reachFeet,
+ startBearingDegrees: startBearingDegrees + lotCorner.assumedNorthClockwiseDegrees,
+ arcDegrees, coverageEstimated: true,
+ note: note ?? "Position read from annotated printout; reach and arc estimated.",
+ })
+ .withId(`sprinkler:zone-${zone}-${number}`)
+ .inGroup(zones[zone - 1])
+ .add(offset(lotCorner), east(ft(eastFeet)), south(ft(southFeet)));
+}
+
export const irrigationFeatures = [
- new Sprinkler("Front bed sprinkler").add(offset(lotCorner), south(ft(15)), east(ft(10))),
- new Sprinkler("Garage side sprinkler").add(offset(lotCorner), south(ft(92)), east(ft(40))),
-];
\ No newline at end of file
+ // Zone 1: boulevard north of the entry walk, and north front lawn.
+ head(1, 1, "Boulevard northwest", -18.5, 0.5, "mist", 10, 90, 90),
+ head(1, 2, "Boulevard northeast", -5, 0.5, "mist", 10, 180, 90),
+ head(1, 3, "Boulevard southwest", -18.5, 11.5, "mist", 10, 0, 90),
+ head(1, 4, "Boulevard southeast", -5, 11.5, "mist", 10, 270, 90),
+ head(1, 5, "Front lawn northwest", 5.5, 10, "mist", 10, 0, 90),
+ head(1, 6, "Front walk bend", 19, 10, "mist", 10, 270, 90),
+ head(1, 7, "Front lawn west", 5.5, 17, "mist", 15, 90, 90),
+ head(1, 8, "Front porch edge", 22, 17, "mist", 16, 180, 90),
+ head(1, 9, "Front lawn south", 17.5, 32.5, "mist", 16, 270, 90),
+
+ // Zone 2: paired heads down the longer southern boulevard strip.
+ head(2, 1, "Boulevard northwest", -18.5, 15.5, "mist", 12, 90, 90),
+ head(2, 2, "Boulevard northeast", -5, 15.5, "mist", 12, 180, 90),
+ head(2, 3, "Boulevard west upper", -18.5, 28, "mist", 12, 0, 180),
+ head(2, 4, "Boulevard east upper", -5, 28, "mist", 12, 180, 180),
+ head(2, 5, "Boulevard west lower", -18.5, 39, "mist", 12, 0, 180),
+ head(2, 6, "Boulevard east lower", -5, 39, "mist", 12, 180, 180),
+ head(2, 7, "Boulevard southwest", -18.5, 50.5, "mist", 12, 0, 90),
+ head(2, 8, "Boulevard southeast", -5, 50.5, "mist", 12, 270, 90),
+
+ // Zone 3: south front lawn and the narrow lawn along the house.
+ head(3, 1, "Front west upper", 5, 28, "rotator", 12, 90, 90),
+ head(3, 2, "Front west lower", 5.5, 43, "rotator", 12, 0, 90),
+ head(3, 3, "Porch southwest", 28, 37.5, "rotator", 10, 180, 90),
+ head(3, 4, "South fence by front bed", 26, 49.5, "rotator", 15, 270, 90),
+ head(3, 5, "South bed northeast", 42, 38, "rotator", 12, 90, 90),
+ head(3, 6, "South fence by bed", 44, 49.5, "rotator", 12, 0, 90),
+ head(3, 7, "House south wall", 57, 31, "rotator", 12, 90, 180),
+
+ // Zone 4: rear lawn. The adjacent head marked 2 belongs to zone 2.
+ head(4, 1, "Rear lawn northwest", 80, 6, "rotator", 15, 90, 90),
+ head(4, 2, "Rear lawn northeast", 106, 6, "rotator", 15, 180, 90),
+ head(4, 3, "Rear lawn southwest", 80, 23.5, "rotator", 15, 0, 90),
+ head(4, 4, "Rear lawn southeast", 105, 25, "rotator", 15, 270, 90),
+ head(2, 9, "House southeast mist", 82, 34.5, "mist", 12, 90, 90,
+ "Confirmed zone 2 mist head. Position and coverage estimated.").withId("sprinkler:zone-4-5"),
+ head(4, 6, "Rear path rotator", 93, 29, "rotator", 15, 90, 90,
+ "Confirmed zone 4 rotator. Position and coverage estimated."),
+ head(4, 7, "South fence west", 86, 50, "rotator", 15, 0, 90),
+ head(4, 8, "South fence by garage", 112, 49.5, "rotator", 15, 270, 90),
+];
diff --git a/src/data/pavement.js b/src/data/pavement.js
index fd11297..eb86810 100644
--- a/src/data/pavement.js
+++ b/src/data/pavement.js
@@ -1,4 +1,6 @@
-import { defineGroup, Driveway, Walkway } from "../lib/yard-features.js";
+import { defineGroup, Driveway, Street, Walkway } from "../lib/yard-features.js";
+import * as geo from "../lib/geometry.js";
+import { lotCorner } from "./anchors.js";
export const pavedSurfacesGroup = defineGroup("group:paved-surfaces", "Paved Surfaces", {
categoryId: "structures",
@@ -52,5 +54,32 @@ const stepPad = new Walkway("Step pad").inGroup(pavedSurfacesGroup).trace([
[-100.89837111222856, 46.82674500346631],
]);
-export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad];
+// The front fence runs along the west edge of the 51-foot-deep lot.
+// Put the sidewalk outside it, with roughly 15 feet beyond either end.
+const frontSidewalk = new Walkway("Front sidewalk")
+ .inGroup(pavedSurfacesGroup)
+ .add(geo.offset(lotCorner), geo.north(geo.ft(15)))
+ .add(geo.offset(lotCorner), geo.north(geo.ft(15)), geo.west(geo.ft(4.5)))
+ .add(geo.offset(lotCorner), geo.south(geo.ft(51 + 15)), geo.west(geo.ft(4.5)))
+ .add(geo.offset(lotCorner), geo.south(geo.ft(51 + 15)));
+
+// Near curb is 19 feet outside the fence; the far curb is another 40 feet west.
+const frontStreet = new Street("6th Avenue NW")
+ .inGroup(pavedSurfacesGroup)
+ .add(geo.offset(lotCorner), geo.north(geo.ft(15)), geo.west(geo.ft(19)))
+ .add(geo.offset(lotCorner), geo.north(geo.ft(15)), geo.west(geo.ft(19 + 40)))
+ .add(geo.offset(lotCorner), geo.south(geo.ft(51 + 15)), geo.west(geo.ft(19 + 40)))
+ .add(geo.offset(lotCorner), geo.south(geo.ft(51 + 15)), geo.west(geo.ft(19)));
+
+// The surveyed front walk meets the fence about 13.15 feet south of lotCorner.
+// Center the 3-foot walk there, spanning the boulevard from sidewalk to curb.
+const frontWalkCenterSouthFeet = 13.15;
+const boulevardWalk = new Walkway("Boulevard walk")
+ .inGroup(pavedSurfacesGroup)
+ .add(geo.offset(lotCorner), geo.south(geo.ft(frontWalkCenterSouthFeet - 1.5)), geo.west(geo.ft(4.5)))
+ .add(geo.offset(lotCorner), geo.south(geo.ft(frontWalkCenterSouthFeet - 1.5)), geo.west(geo.ft(19)))
+ .add(geo.offset(lotCorner), geo.south(geo.ft(frontWalkCenterSouthFeet + 1.5)), geo.west(geo.ft(19)))
+ .add(geo.offset(lotCorner), geo.south(geo.ft(frontWalkCenterSouthFeet + 1.5)), geo.west(geo.ft(4.5)));
+
+export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad, frontSidewalk, frontStreet, boulevardWalk];
export const pavementGroups = [pavedSurfacesGroup];
diff --git a/src/data/visibility-tree-manifest.json b/src/data/visibility-tree-manifest.json
index f7a2ae8..09d20e1 100644
--- a/src/data/visibility-tree-manifest.json
+++ b/src/data/visibility-tree-manifest.json
@@ -3,19 +3,16 @@
"nodes": [
"visibility:category:plants",
"visibility:category:plants>visibility:type:flower",
- "visibility:category:plants>visibility:type:flower>visibility:feature:83",
- "visibility:category:plants>visibility:type:flower>visibility:feature:84",
- "visibility:category:plants>visibility:type:flower>visibility:feature:85",
"visibility:category:plants>visibility:type:flower>visibility:feature:86",
"visibility:category:plants>visibility:type:flower>visibility:feature:87",
"visibility:category:plants>visibility:type:flower>visibility:feature:88",
"visibility:category:plants>visibility:type:flower>visibility:feature:89",
"visibility:category:plants>visibility:type:flower>visibility:feature:90",
"visibility:category:plants>visibility:type:flower>visibility:feature:91",
+ "visibility:category:plants>visibility:type:flower>visibility:feature:92",
+ "visibility:category:plants>visibility:type:flower>visibility:feature:93",
+ "visibility:category:plants>visibility:type:flower>visibility:feature:94",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily",
- "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:53",
- "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:54",
- "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:55",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:56",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:57",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:58",
@@ -43,10 +40,10 @@
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:80",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:81",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:82",
+ "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:83",
+ "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:84",
+ "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:85",
"visibility:category:plants>visibility:type:tree",
- "visibility:category:plants>visibility:type:tree>visibility:feature:92",
- "visibility:category:plants>visibility:type:tree>visibility:feature:93",
- "visibility:category:plants>visibility:type:tree>visibility:feature:94",
"visibility:category:plants>visibility:type:tree>visibility:feature:95",
"visibility:category:plants>visibility:type:tree>visibility:feature:96",
"visibility:category:plants>visibility:type:tree>visibility:feature:97",
@@ -56,31 +53,34 @@
"visibility:category:plants>visibility:type:tree>visibility:feature:101",
"visibility:category:plants>visibility:type:tree>visibility:feature:102",
"visibility:category:plants>visibility:type:tree>visibility:feature:103",
- "visibility:category:plants>visibility:type:tree>visibility:feature:114",
- "visibility:category:plants>visibility:type:tree>visibility:feature:115",
- "visibility:category:plants>visibility:type:tree>visibility:feature:116",
+ "visibility:category:plants>visibility:type:tree>visibility:feature:104",
+ "visibility:category:plants>visibility:type:tree>visibility:feature:105",
+ "visibility:category:plants>visibility:type:tree>visibility:feature:106",
"visibility:category:plants>visibility:type:tree>visibility:feature:117",
+ "visibility:category:plants>visibility:type:tree>visibility:feature:118",
+ "visibility:category:plants>visibility:type:tree>visibility:feature:119",
+ "visibility:category:plants>visibility:type:tree>visibility:feature:120",
"visibility:category:plants>visibility:type:bush",
- "visibility:category:plants>visibility:type:bush>visibility:feature:109",
- "visibility:category:plants>visibility:type:bush>visibility:feature:110",
- "visibility:category:plants>visibility:type:bush>visibility:feature:111",
"visibility:category:plants>visibility:type:bush>visibility:feature:112",
"visibility:category:plants>visibility:type:bush>visibility:feature:113",
+ "visibility:category:plants>visibility:type:bush>visibility:feature:114",
+ "visibility:category:plants>visibility:type:bush>visibility:feature:115",
+ "visibility:category:plants>visibility:type:bush>visibility:feature:116",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees",
- "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:104",
- "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:105",
- "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:106",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:107",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:108",
+ "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:109",
+ "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:110",
+ "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:111",
"visibility:category:structures",
"visibility:category:structures>visibility:type:fence",
"visibility:category:structures>visibility:type:fence>visibility:feature:1",
"visibility:category:structures>visibility:type:flowerBed",
- "visibility:category:structures>visibility:type:flowerBed>visibility:feature:48",
- "visibility:category:structures>visibility:type:flowerBed>visibility:feature:49",
- "visibility:category:structures>visibility:type:flowerBed>visibility:feature:50",
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:51",
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:52",
+ "visibility:category:structures>visibility:type:flowerBed>visibility:feature:53",
+ "visibility:category:structures>visibility:type:flowerBed>visibility:feature:54",
+ "visibility:category:structures>visibility:type:flowerBed>visibility:feature:55",
"visibility:category:structures>visibility:group:buildings",
"visibility:category:structures>visibility:group:buildings>visibility:feature:40",
"visibility:category:structures>visibility:group:buildings>visibility:feature:41",
@@ -93,9 +93,9 @@
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:46",
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:47",
"visibility:category:infrastructure",
- "visibility:category:infrastructure>visibility:type:sprinkler",
- "visibility:category:infrastructure>visibility:type:sprinkler>visibility:feature:118",
- "visibility:category:infrastructure>visibility:type:sprinkler>visibility:feature:119",
+ "visibility:category:infrastructure>visibility:group:irrigation",
+ null,
+ null,
"visibility:category:reference",
"visibility:category:reference>visibility:type:lotBoundary",
"visibility:category:reference>visibility:type:lotBoundary>visibility:feature:0",
@@ -137,6 +137,45 @@
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:36",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:37",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:38",
- "visibility:category:reference>visibility:group:yard-grid>visibility:feature:39"
+ "visibility:category:reference>visibility:group:yard-grid>visibility:feature:39",
+ "visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:48",
+ "visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:49",
+ "visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:50",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-3",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-4",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:121",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:122",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:123",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:124",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:125",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:126",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:127",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:128",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-1>visibility:feature:129",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:130",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:131",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:132",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:133",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:134",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:135",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:136",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:137",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-3>visibility:feature:138",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-3>visibility:feature:139",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-3>visibility:feature:140",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-3>visibility:feature:141",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-3>visibility:feature:142",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-3>visibility:feature:143",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-3>visibility:feature:144",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-4>visibility:feature:145",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-4>visibility:feature:146",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-4>visibility:feature:147",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-4>visibility:feature:148",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-2>visibility:feature:149",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-4>visibility:feature:150",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-4>visibility:feature:151",
+ "visibility:category:infrastructure>visibility:group:irrigation>visibility:group:irrigation-zone-4>visibility:feature:152"
]
}
diff --git a/src/data/yard.js b/src/data/yard.js
index 34fabbe..f849832 100644
--- a/src/data/yard.js
+++ b/src/data/yard.js
@@ -2,7 +2,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 { irrigationFeatures, irrigationGroups } from "./irrigation.js";
import { pavementFeatures, pavementGroups } from "./pavement.js";
import { plantFeatures, plantGroups } from "./plants.js";
@@ -20,5 +20,6 @@ export const yardGeoJSON = collectYardData(
...gridGroups,
...pavementGroups,
...plantGroups,
+ ...irrigationGroups,
],
);
diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js
index dc0c27f..45a467a 100644
--- a/src/lib/feature-types.js
+++ b/src/lib/feature-types.js
@@ -124,6 +124,19 @@ export const featureTypes = {
fillOpacity: 0.8,
},
},
+ street: {
+ label: "Street",
+ collectionLabel: "Streets",
+ categoryId: "structures",
+ geometryType: "Polygon",
+ detailFields: {},
+ style: {
+ color: "#62666b",
+ weight: 2,
+ fillColor: "#92979d",
+ fillOpacity: 0.45,
+ },
+ },
flowerBed: {
label: "Flower Bed",
collectionLabel: "Flower Beds",
@@ -279,13 +292,23 @@ export const featureTypes = {
zone: "string",
headType: "string",
flowRateGallonsPerMinute: "number",
+ reachFeet: "number",
+ startBearingDegrees: "number",
+ arcDegrees: "number",
+ coverageEstimated: "boolean",
+ note: "string",
},
- style: {
- color: "#2d7dd2",
+ style: (details) => ({
+ color: details.headType === "mist" ? "#087f8c" : "#3457b2",
radius: 5,
fillColor: "#88bdf2",
fillOpacity: 0.95,
- },
+ ...(details.headType === "mist" || details.headType === "rotator" ? {
+ iconUrl: `./src/assets/icons/sprinkler-${details.headType}.svg`,
+ iconOpacity: 0.2,
+ iconSize: [28, 28],
+ } : {}),
+ }),
},
bush: {
label: "Bush",
diff --git a/src/lib/irrigation.js b/src/lib/irrigation.js
new file mode 100644
index 0000000..6db6462
--- /dev/null
+++ b/src/lib/irrigation.js
@@ -0,0 +1,33 @@
+import { east, ft, north, offset, point } from "./geometry.js";
+
+// Bearings are clockwise from true north. Add the anchor's rotation when
+// authoring a bearing relative to the lot. Reach is a radius, in feet.
+// Missing coverage stays hidden until the head has been located and estimated.
+export function sprinklerCoverage(feature) {
+ const { reachFeet, startBearingDegrees = 0, arcDegrees = 360 } = feature.properties.details;
+ if (!Number.isFinite(reachFeet) || reachFeet <= 0) return null;
+ if (!Number.isFinite(startBearingDegrees) || !Number.isFinite(arcDegrees) ||
+ arcDegrees <= 0 || arcDegrees > 360) return null;
+ const [lon, lat] = feature.geometry.coordinates;
+ const origin = point(lat, lon);
+ const steps = Math.ceil(arcDegrees / 5);
+ const ring = arcDegrees === 360 ? [] : [[lon, lat]];
+ for (let i = 0; i <= steps; i++) {
+ const angle = (startBearingDegrees + arcDegrees * i / steps) * Math.PI / 180;
+ const tip = offset(origin, north(ft(reachFeet) * Math.cos(angle)),
+ east(ft(reachFeet) * Math.sin(angle)));
+ ring.push([tip.lon, tip.lat]);
+ }
+ ring.push(ring[0]);
+ return ring;
+}
+
+export function sprinklerCoverageStyle(details) {
+ return {
+ color: details.headType === "mist" ? "#087f8c" : "#3457b2",
+ weight: 1,
+ opacity: 0.65,
+ fillOpacity: 0.2,
+ dashArray: details.headType === "mist" ? "2 4" : "7 4",
+ };
+}
diff --git a/src/lib/print-map.js b/src/lib/print-map.js
new file mode 100644
index 0000000..36bfb8a
--- /dev/null
+++ b/src/lib/print-map.js
@@ -0,0 +1,154 @@
+import { sprinklerCoverage, sprinklerCoverageStyle } from "./irrigation.js";
+import { resolveFeatureStyle } from "./feature-types.js";
+
+const WIDTH = 1000;
+const HEIGHT = 750;
+const PADDING = 24;
+const FEET_PER_METER = 3.280839895;
+
+// Inverse of offset()'s anchor rotation: local north is up on paper.
+export function projectPrintCoordinate([lon, lat], anchor) {
+ const angle = (anchor.assumedNorthClockwiseDegrees ?? 0) * Math.PI / 180;
+ const north = (lat - anchor.lat) * 111320;
+ const east = (lon - anchor.lon) * 111320 * Math.cos(anchor.lat * Math.PI / 180);
+ return [east * Math.cos(angle) - north * Math.sin(angle),
+ -(north * Math.cos(angle) + east * Math.sin(angle))];
+}
+
+function svgElement(name, attributes = {}, text) {
+ const element = document.createElementNS("http://www.w3.org/2000/svg", name);
+ for (const [key, value] of Object.entries(attributes)) {
+ if (value !== undefined) element.setAttribute(key, value);
+ }
+ if (text !== undefined) element.textContent = text;
+ return element;
+}
+
+export function buildPrintMap(collection, fullCollection, anchor, date) {
+ const project = (coordinate) => projectPrintCoordinate(coordinate, anchor);
+ const points = [];
+ const visit = (coordinates) => {
+ if (typeof coordinates[0] === "number") points.push(project(coordinates));
+ else coordinates.forEach(visit);
+ };
+ // Keep the full yard in view even when reference features are unchecked.
+ // The authored grid extends well beyond the yard and must not shrink the map.
+ for (const feature of fullCollection.features) {
+ if (feature.properties.kind !== "yardGrid") visit(feature.geometry.coordinates);
+ }
+ const xs = points.map(([x]) => x);
+ const ys = points.map(([, y]) => y);
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const minY = Math.min(...ys);
+ const maxY = Math.max(...ys);
+ const scale = Math.min((WIDTH - 2 * PADDING) / (maxX - minX || 1),
+ (HEIGHT - 2 * PADDING) / (maxY - minY || 1));
+ const screen = (coordinate) => {
+ const [x, y] = project(coordinate);
+ return [WIDTH / 2 + (x - (minX + maxX) / 2) * scale,
+ HEIGHT / 2 + (y - (minY + maxY) / 2) * scale];
+ };
+ const svg = svgElement("svg", {
+ viewBox: `0 0 ${WIDTH} ${HEIGHT}`, role: "img",
+ "aria-label": `Yard sketch map for ${date}, aligned with the lot`,
+ });
+ svg.append(svgElement("title", {}, `204 6th Avenue NW — ${date}`));
+ const defs = svgElement("defs");
+ const clip = svgElement("clipPath", { id: "print-map-clip" });
+ clip.append(svgElement("rect", { x: PADDING, y: PADDING,
+ width: WIDTH - 2 * PADDING, height: HEIGHT - 2 * PADDING }));
+ defs.append(clip);
+ svg.append(defs);
+ const features = svgElement("g", { "clip-path": "url(#print-map-clip)" });
+ // Draw the grid first so it doesn't cover plants or structures.
+ const ordered = [...collection.features].sort((a, b) =>
+ Number(b.properties.kind === "yardGrid") - Number(a.properties.kind === "yardGrid"));
+ for (const feature of ordered) {
+ if (feature.properties.kind !== "sprinkler") continue;
+ const ring = sprinklerCoverage(feature);
+ if (!ring) continue;
+ const style = sprinklerCoverageStyle(feature.properties.details);
+ features.append(svgElement("path", {
+ d: ring.map((coordinate, i) => `${i ? "L" : "M"}${screen(coordinate).join(" ")}`).join(" ") + " Z",
+ stroke: style.color, "stroke-width": style.weight,
+ "stroke-dasharray": style.dashArray, "stroke-opacity": style.opacity,
+ fill: style.color, "fill-opacity": style.fillOpacity,
+ }));
+ }
+ for (const feature of ordered) {
+ const style = resolveFeatureStyle(feature.properties.kind, feature.properties.details);
+ const { type, coordinates } = feature.geometry;
+ const attributes = {
+ stroke: style.color ?? "#333", "stroke-width": Math.max(0.8, (style.weight ?? 2) * 0.65),
+ "stroke-opacity": style.opacity ?? 1,
+ "stroke-dasharray": style.dashArray,
+ fill: type === "LineString" ? "none" : (style.fillColor ?? style.color ?? "#333"),
+ // Light fills leave pencil marks easy to see; outlines retain type colors.
+ "fill-opacity": Math.min(style.fillOpacity ?? 0.2, 0.18),
+ "stroke-linejoin": "round", "stroke-linecap": "round",
+ };
+ let shape;
+ if (type === "Point") {
+ const [cx, cy] = screen(coordinates);
+ if (feature.properties.kind === "sprinkler" && style.iconUrl) {
+ const icon = svgElement("image", {
+ href: style.iconUrl, x: cx - 9, y: cy - 9, width: 18, height: 18,
+ opacity: style.iconOpacity ?? 1,
+ });
+ icon.append(svgElement("title", {}, feature.properties.name));
+ features.append(icon);
+ if (feature.properties.details.zone) {
+ features.append(svgElement("text", {
+ x: cx + 10, y: cy + 4, fill: "#234254", "font-size": 10,
+ "font-family": "sans-serif", stroke: "white", "stroke-width": 3,
+ "paint-order": "stroke",
+ }, feature.properties.details.zone));
+ }
+ continue;
+ }
+ const radius = style.radiusMeters ?? (style.iconDiameterMeters !== undefined
+ ? style.iconDiameterMeters / 2 : undefined);
+ shape = svgElement("circle", { ...attributes, cx, cy,
+ r: radius !== undefined ? Math.max(1.5, radius * scale) : (style.radius ?? 5) * 0.65 });
+ } else {
+ const rings = type === "Polygon" ? coordinates : [coordinates];
+ const d = rings.map((ring) => ring.map((coordinate, index) =>
+ `${index ? "L" : "M"}${screen(coordinate).join(" ")}`).join(" ") +
+ (type === "Polygon" ? " Z" : "")).join(" ");
+ shape = svgElement("path", { ...attributes, d, "fill-rule": "evenodd" });
+ }
+ shape.append(svgElement("title", {}, feature.properties.name));
+ features.append(shape);
+ }
+ svg.append(features);
+ const label = svgElement("g", { fill: "#222", "font-family": "sans-serif", "font-size": 12 });
+ label.append(svgElement("rect", { x: 20, y: 4, width: 310, height: 22, fill: "white" }));
+ label.append(svgElement("text", { x: 24, y: 19 }, `204 6th Avenue NW · Map date ${date}`));
+ if (collection.features.some((feature) => feature.properties.kind === "sprinkler")) {
+ label.append(svgElement("rect", { x: WIDTH - 330, y: HEIGHT - 36,
+ width: 310, height: 30, fill: "white" }));
+ for (const [index, type] of ["mist", "rotator"].entries()) {
+ const x = WIDTH - 325 + index * 82;
+ label.append(svgElement("image", { href: `./src/assets/icons/sprinkler-${type}.svg`,
+ x, y: HEIGHT - 32, width: 18, height: 18 }));
+ label.append(svgElement("text", { x: x + 21, y: HEIGHT - 19 },
+ type === "mist" ? "Mist" : "Rotator"));
+ }
+ label.append(svgElement("text", { x: WIDTH - 158, y: HEIGHT - 19,
+ "font-size": 10 }, "Coverage estimated"));
+ }
+ const bar = 20 / FEET_PER_METER * scale;
+ label.append(svgElement("rect", { x: 20, y: HEIGHT - 40, width: bar + 16, height: 36, fill: "white" }));
+ label.append(svgElement("path", { d: `M24 ${HEIGHT - 24} v8 h${bar} v-8`, fill: "none", stroke: "#222", "stroke-width": 2 }));
+ label.append(svgElement("text", { x: 24 + bar / 2, y: HEIGHT - 25, "text-anchor": "middle" }, "20 ft"));
+ const north = svgElement("g", { transform: `translate(${WIDTH - 55} 65)` });
+ north.append(svgElement("circle", { r: 42, fill: "white" }));
+ const arrow = svgElement("g", { transform: `rotate(${-(anchor.assumedNorthClockwiseDegrees ?? 0)})` });
+ arrow.append(svgElement("path", { d: "M0 25 V-20 M-6 -10 L0 -25 L6 -10", fill: "none", stroke: "#222", "stroke-width": 2 }));
+ arrow.append(svgElement("text", { x: 0, y: -30, "text-anchor": "middle" }, "N"));
+ north.append(arrow);
+ label.append(north);
+ svg.append(label);
+ return svg;
+}
diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js
index 9f2e30a..9d097f8 100644
--- a/src/lib/yard-features.js
+++ b/src/lib/yard-features.js
@@ -284,6 +284,12 @@ export class Driveway extends PolygonFeature {
}
}
+export class Street extends PolygonFeature {
+ constructor(name = "Street", details = {}) {
+ super("street", name, details);
+ }
+}
+
export class FlowerBed extends PolygonFeature {
constructor(name = "Flower Bed", details = {}) {
super("flowerBed", name, details);
diff --git a/src/main.js b/src/main.js
index ea8c7b0..16ce114 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,12 +1,16 @@
import {
Map as MapLibreMap,
NavigationControl,
+ Marker,
Popup,
addProtocol,
} from "../vendor/maplibre/maplibre-gl.mjs";
import { yardGeoJSON } from "./data/yard.js";
import { CheckboxTree, loadManifest } from "../vendor/checkbox-tree/checkbox-tree.js";
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
+import { lotCorner } from "./data/anchors.js";
+import { sprinklerCoverage, sprinklerCoverageStyle } from "./lib/irrigation.js";
+import { buildPrintMap } from "./lib/print-map.js";
const MAP_MAX_ZOOM = 24;
const FEET_PER_METER = 3.280839895;
@@ -52,6 +56,7 @@ map.addControl(new NavigationControl(), "bottom-right");
let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date;
let selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => index));
+let renderedMarkers = [];
let renderedLayerIds = [];
let renderedSourceIds = [];
let renderedLayerHandlers = [];
@@ -80,6 +85,24 @@ map.on("error", (event) => {
statusElement.hidden = false;
});
+function filterFeatureCollection(collection, date, visibleIndexes) {
+ return { ...collection, features: collection.features.filter(
+ (feature, index) => visibleIndexes.has(index) && featureExistsOnDate(feature, date),
+ ) };
+}
+
+function preparePrintMap() {
+ document.getElementById("print-sheet").replaceChildren(buildPrintMap(
+ filterFeatureCollection(yardGeoJSON, selectedTimelineDate, selectedFeatureIndexes),
+ yardGeoJSON, lotCorner, selectedTimelineDate,
+ ));
+}
+window.addEventListener("beforeprint", preparePrintMap);
+document.getElementById("print-map").addEventListener("click", () => {
+ preparePrintMap();
+ window.print();
+});
+
function renderYardForDate(date) {
removeRenderedYard();
const features = yardGeoJSON.features.filter(
@@ -92,6 +115,8 @@ function renderYardForDate(date) {
}
function removeRenderedYard() {
+ for (const marker of renderedMarkers) marker.remove();
+ renderedMarkers = [];
for (const { layerId, event, handler } of renderedLayerHandlers) {
map.off(event, layerId, handler);
}
@@ -111,6 +136,10 @@ function removeRenderedYard() {
}
function addFeatureLayer(feature, index) {
+ if (feature.properties.kind === "sprinkler") {
+ addSprinkler(feature, index);
+ return;
+ }
const sourceId = `yard-source-${index}`;
const layerId = `yard-layer-${index}`;
const style = resolveFeatureStyle(feature.properties.kind, feature.properties.details);
@@ -148,6 +177,57 @@ function addFeatureLayer(feature, index) {
}
}
+function addSprinkler(feature, index) {
+ const details = feature.properties.details;
+ const style = resolveFeatureStyle("sprinkler", details);
+ const ring = sprinklerCoverage(feature);
+ if (ring) {
+ const source = `sprinkler-coverage-${index}`;
+ const coverageStyle = sprinklerCoverageStyle(details);
+ map.addSource(source, { type: "geojson", data: {
+ type: "Feature", properties: {},
+ geometry: { type: "Polygon", coordinates: [ring] },
+ } });
+ renderedSourceIds.push(source);
+ for (const layer of [
+ { id: `${source}-fill`, source, type: "fill", paint: {
+ "fill-color": coverageStyle.color, "fill-opacity": coverageStyle.fillOpacity,
+ } },
+ { id: `${source}-line`, source, type: "line", paint: {
+ "line-color": coverageStyle.color, "line-opacity": coverageStyle.opacity,
+ "line-width": coverageStyle.weight,
+ "line-dasharray": coverageStyle.dashArray.split(/\s+/).map(Number),
+ } },
+ ]) {
+ map.addLayer(layer);
+ renderedLayerIds.push(layer.id);
+ }
+ }
+ const element = document.createElement("div");
+ element.style.cursor = "pointer";
+ const image = document.createElement("img");
+ image.src = style.iconUrl;
+ image.alt = feature.properties.name;
+ image.width = 28;
+ image.height = 28;
+ image.style.display = "block";
+ image.style.opacity = style.iconOpacity ?? 1;
+ element.append(image);
+ const label = document.createElement("span");
+ label.className = "sprinkler-zone-label";
+ label.textContent = `Zone ${details.zone}`;
+ label.style.position = "absolute";
+ label.style.whiteSpace = "nowrap";
+ label.style.left = "50%";
+ label.style.transform = "translateX(-50%)";
+ element.append(label);
+ const marker = new Marker({ element })
+ .setLngLat(feature.geometry.coordinates)
+ .setPopup(new Popup({ offset: 16 }).setHTML(buildPopupContent(feature.properties)))
+ .addTo(map);
+ renderedMarkers.push(marker);
+}
+
function buildMapLibreLayer(id, source, feature, style) {
if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") {
return {
@@ -550,6 +630,13 @@ function shouldShowDetailField(key, details = {}) {
function formatDetailLabel(key) {
const labels = {
+ zone: "Zone",
+ headType: "Head type",
+ reachFeet: "Reach (radius)",
+ startBearingDegrees: "Start bearing (true north)",
+ arcDegrees: "Spray arc",
+ coverageEstimated: "Estimated coverage",
+ flowRateGallonsPerMinute: "Flow (gal/min)",
canopyDiameter: "Canopy diameter",
height: "Height",
measurements: "Measurements",
@@ -574,8 +661,16 @@ function formatDetailLabel(key) {
}
function formatDetailValue(key, value) {
- if (key === "canopyDiameter" || key === "height") return formatMetersAsFeet(value);
- if (key === "offsetFeet" || key === "spacingFeet") return `${value} ft`;
+ if (key === "canopyDiameter" || key === "height") {
+ return formatMetersAsFeet(value);
+ }
+
+ if (key === "coverageEstimated") return value ? "Yes" : "No";
+ if (key === "startBearingDegrees" || key === "arcDegrees") return `${value}°`;
+ if (key === "reachFeet" || key === "offsetFeet" || key === "spacingFeet") {
+ return `${value} ft`;
+ }
+
return String(value);
}
diff --git a/styles.css b/styles.css
index 351f93e..973aac7 100644
--- a/styles.css
+++ b/styles.css
@@ -386,3 +386,90 @@ main,
padding: 0.55rem;
}
}
+
+#print-map {
+ padding: 0.65rem 1rem;
+ border: 1px solid var(--accent);
+ border-radius: 8px;
+ background: var(--accent);
+ color: white;
+ font: inherit;
+ cursor: pointer;
+}
+
+#print-map:focus-visible {
+ outline: 2px solid var(--ink);
+ outline-offset: 3px;
+}
+
+.print-hint {
+ color: var(--muted);
+ font-size: 0.85rem;
+ line-height: 1.4;
+}
+
+#print-sheet {
+ display: none;
+}
+
+@page {
+ size: landscape;
+ margin: 0.25in;
+}
+
+@media print {
+ html,
+ body {
+ min-height: 0;
+ background: white;
+ }
+
+ .app-shell,
+ .error-overlay {
+ display: none !important;
+ }
+
+ #print-sheet {
+ display: block;
+ position: fixed;
+ inset: 0;
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+
+ #print-sheet svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.sprinkler-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-top: 1rem;
+ font-size: 0.9rem;
+}
+
+.sprinkler-legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.sprinkler-legend img {
+ width: 28px;
+ height: 28px;
+}
+
+.sprinkler-zone-label {
+ border: 0;
+ box-shadow: none;
+ background: rgba(255, 255, 255, 0.85);
+ padding: 0 3px;
+ font-size: 10px;
+ line-height: 1.3;
+ color: #234254;
+}
+.sprinkler-zone-label::before { display: none; }