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.

+ + +
+ 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; }