From 2a52e0546e362a6c7d9b2f360d956d13e3bf245e Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Fri, 26 Jun 2026 11:23:09 -0500 Subject: [PATCH] Prototype --- .vscode/settings.json | 8 ++ AGENTS.md | 97 ++++++++++++++ README.md | 109 +++++++++++++++ buildings.gpkg | Bin 0 -> 217088 bytes index.html | 50 +++++++ package.json | 5 + src/data/anchors.js | 7 + src/data/boundaries.js | 19 +++ src/data/buildings.js | 42 ++++++ src/data/irrigation.js | 8 ++ src/data/pavement.js | 51 +++++++ src/data/plants.js | 106 +++++++++++++++ src/data/settings.js | 1 + src/data/yard.js | 20 +++ src/lib/feature-types.js | 125 ++++++++++++++++++ src/lib/geometry.js | 204 ++++++++++++++++++++++++++++ src/lib/yard-features.js | 279 +++++++++++++++++++++++++++++++++++++++ src/main.js | 139 +++++++++++++++++++ styles.css | 136 +++++++++++++++++++ 19 files changed, 1406 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 buildings.gpkg create mode 100644 index.html create mode 100644 package.json create mode 100644 src/data/anchors.js create mode 100644 src/data/boundaries.js create mode 100644 src/data/buildings.js create mode 100644 src/data/irrigation.js create mode 100644 src/data/pavement.js create mode 100644 src/data/plants.js create mode 100644 src/data/settings.js create mode 100644 src/data/yard.js create mode 100644 src/lib/feature-types.js create mode 100644 src/lib/geometry.js create mode 100644 src/lib/yard-features.js create mode 100644 src/main.js create mode 100644 styles.css diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9c25661 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "chat.tools.terminal.autoApprove": { + "/^python3 -m http\\.server 4173$/": { + "approve": true, + "matchCommandLine": true + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..047b4ed --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# Yard Map Agent Notes + +This file is a fast handoff for future coding agents working in this repo. + +## What this project is + +Static client-side yard mapping app. + +- Rendering: Leaflet. +- Basemap: public OSM raster tiles. +- Yard data: authored in JavaScript modules, converted to GeoJSON in the browser. +- Runtime: plain ES modules, no bundler. + +## Current architecture + +Core files: + +- `src/lib/geometry.js`: absolute points, local offsets, feet conversion, per-anchor rotation support. +- `src/lib/feature-types.js`: feature-kind style defaults. +- `src/lib/yard-features.js`: typed feature classes, stable ids, groups, parent-child relationships, GeoJSON export. +- `src/data/*.js`: yard content split by domain. +- `src/main.js`: Leaflet map, legend, popup rendering. + +Top-level exported data shape from `src/data/yard.js`: + +- `type: "FeatureCollection"` +- `features: [...]` +- `groups: [...]` + +Each feature currently exports: + +- `properties.id` +- `properties.kind` +- `properties.label` +- `properties.name` +- `properties.parentId` +- `properties.groupIds` +- `properties.details` +- `properties.style` + +## Important decisions already made + +1. Data is JS-authored on purpose. + This allows mixing exact imported geometry with expressions like `offset(anchor, east(ft(10)))`. + +2. Styling belongs to feature types, not individual features by default. + If you need a new semantic type, prefer adding a new typed feature or feature-type definition rather than hand-styling scattered instances. + +3. Local rotation is per anchor. + `offset(...)` reads `assumedNorthClockwiseDegrees` from the anchor point used as the origin. Do not reintroduce a global lot rotation unless there is a strong reason. + +4. Grouping and parent-child metadata are already part of the data model. + Future visibility controls should consume `groupIds` and `parentId` rather than inventing a second relationship system. + +5. The GPKG was only a seed source. + The app does not depend on GIS tooling at runtime. + +## Known data caveat + +`buildings.gpkg` has a CRS issue in `lot_boundary`. + +- The usable lot polygon was `fid = 2`. +- It had to be treated as `EPSG:26914` during extraction. +- Do not trust that layer's metadata blindly if you re-import from the GPKG. + +## What is already modeled + +- Porch and deck are children of the main house and also part of the `House Parts` group. +- Medora junipers are part of the `Juniper Trees` group. +- A test tree exists that is offset from the top-left lot corner using per-anchor rotation. + +## Likely next steps + +1. Add show/hide controls driven by `yardGeoJSON.groups` and possibly `parentId`. +2. Decide whether toggles should be by feature kind, by group, by parent object, or a combination. +3. If closer basemap zoom fidelity matters, migrate from raster OSM tiles to a vector tile or self-hosted basemap. + +## Useful validation commands + +Validate data export: + +```bash +node -e "import('./src/data/yard.js').then(({ yardGeoJSON }) => console.log(yardGeoJSON.features.length))" +``` + +Run local server: + +```bash +python3 -m http.server 4173 +``` + +## Editing guidance + +- Preserve the typed-feature pattern. +- Prefer adding semantic types or groups over ad hoc properties when possible. +- Keep authored geometry readable; avoid generated noise unless the user explicitly wants bulk-import output. +- If adding new anchors, attach rotation metadata to the anchor itself. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a78759 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Yard Map + +Static yard mapping site built with Leaflet and plain browser modules. + +## Status + +The project currently has: + +- A static Leaflet map with OSM raster tiles. +- Client-side JavaScript-authored yard geometry converted to GeoJSON at load time. +- Typed feature classes with centralized styling. +- Per-anchor local coordinate rotation for offset-based authoring. +- Feature relationship metadata (`id`, `parentId`, `groupIds`) and a top-level group catalog for future visibility controls. +- Example yard data mostly copied from `buildings.gpkg` and then preserved as JS modules. + +The next likely product direction is map-layer visibility controls driven by the existing feature groups and parent-child relationships. + +## Authoring model + +Geometry stays in JavaScript, then gets converted to GeoJSON client-side at load time. + +- Use absolute coordinates with `point(lat, lon)`. +- Use absolute GeoJSON-style coordinates with `lngLat(lon, lat)` or `.trace([[lon, lat], ...])`. +- Use local offsets with `offset(origin)` inside `.add(...)`, for example `.add(offset(lotCorner), south(ft(15)), east(ft(10)))`. +- Build typed features with classes like `new House()`, `new Garage()`, `new Tree()`, and `new Sprinkler()` so styles live in one place. +- Add relationships with `.childOf(...)` and `.inGroup(...)` so future UI can show or hide related features together. + +The example yard is now split by concern: + +- `src/data/boundaries.js` +- `src/data/buildings.js` +- `src/data/pavement.js` +- `src/data/plants.js` +- `src/data/irrigation.js` + +The typed feature registry lives in `src/lib/feature-types.js`, feature classes live in `src/lib/yard-features.js`, and shared coordinate helpers live in `src/lib/geometry.js`. + +Local `north`, `south`, `east`, and `west` offsets are rotated per anchor. In this example, `topLeftLotCorner` in `src/data/anchors.js` carries `assumedNorthClockwiseDegrees` from `src/data/settings.js`. A negative value means that anchor's local north is counterclockwise from true north, so local east drifts a bit toward true north. + +Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and planted date for popup rendering. + +Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties, and the collection also exposes a top-level `groups` array. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. + +## Project structure + +- `src/lib/geometry.js`: low-level coordinate and offset helpers. +- `src/lib/feature-types.js`: centralized default styling by feature kind. +- `src/lib/yard-features.js`: typed feature classes, feature/group metadata, and GeoJSON export. +- `src/data/settings.js`: local authoring settings such as lot rotation. +- `src/data/anchors.js`: named anchor points for offset-based authoring. +- `src/data/boundaries.js`: lot boundary and fence features. +- `src/data/buildings.js`: house, garage, porch, deck, and building-related groups. +- `src/data/pavement.js`: driveway and walkway geometry. +- `src/data/plants.js`: flower beds, trees, and tree groups. +- `src/data/irrigation.js`: sprinkler features. +- `src/data/yard.js`: top-level yard collection assembly. +- `src/main.js`: Leaflet rendering, legend generation, and popup rendering. + +## Key decisions + +- Geometry is authored in JavaScript, not GeoJSON, so yard data can mix exact coordinates with local offset expressions. +- Styling is defined by feature type rather than repeated inline for each feature. +- Local offsets rotate per anchor, not globally, so future anchors can carry different orientations. +- Grouping is stored in the data model now so later show/hide behavior can be implemented without restructuring features. +- The example GPKG data was used as a one-time source to seed JS-authored geometry, not as a runtime dependency. + +## GeoPackage notes + +- `buildings.gpkg` was used to seed most sample geometry. +- The `lot_boundary` layer had mixed or incorrect CRS metadata. +- The usable lot polygon was `fid = 2`, and it had to be treated as `EPSG:26914` during one-time extraction before converting to JS coordinates. + +## Validation + +Useful local checks: + +```bash +node -e "import('./src/data/yard.js').then(({ yardGeoJSON }) => console.log(yardGeoJSON.features.length))" +``` + +```bash +python3 -m http.server 4173 +``` + +The first check validates that the authored yard data still imports and exports cleanly. The second runs the static site locally. + +## Run locally + +Because the app uses ES modules, serve it over HTTP instead of opening `index.html` directly. + +```bash +python3 -m http.server 4173 +``` + +Then open . + +## Zoom behavior + +The yard overlays are vector data, so they stay sharp at any zoom level the map allows. +The current basemap is still raster OSM tiles, so above zoom 19 Leaflet reuses and scales those tiles instead of fetching higher-resolution imagery. + +If you want a truly crisp basemap at arbitrary close zoom, you need a vector tile or self-hosted tile source rather than the standard public OSM raster tile endpoint. + +## Future direction + +- Add UI controls for toggling groups and possibly parent-linked feature clusters. +- Decide whether toggles should operate on feature groups, feature kinds, parent-child trees, or all three. +- If sharper basemap zoom is needed, replace the raster OSM background with a vector tile or self-hosted tile solution. +- 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. diff --git a/buildings.gpkg b/buildings.gpkg new file mode 100644 index 0000000000000000000000000000000000000000..764100e6fbd2ecbcc4d1be6e8d34f4c35260cdc3 GIT binary patch literal 217088 zcmeI531A!5o&RSn*<;7Y}Kxqr5CoQ}5-$L(WDW#>QY$?02zc({anvq79 zWXCvRe@aH9cmLk!y*F>3or#wgAd0)N&GkPFYzz%&+*UjzvX`+d#R)2XC#0G zkN^@u0!RP}AOR$R1dsp{Kmtf$J_M|9gj9_O;LW@n-fFkN+v{53ZFwEM@yjdWy|{EI zQ)Q^Ut8{18;c^pr;0p;L0VIF~kN^@u0yzkrbltL|w1=rKdZ&-s*Xt2BZ4lNALosnG z5NR0{qoe+KBLuOo}oU6 z+tt65W@5H(?C9EL>*%t!cW-KK@9b)C@95mvVYLr?`&{mR@5n(jDcfu{ceQo2nXkN3 zCuCzs`$iHnTa`<=)=x_??H1w9Ea!-v$f6EwV|`4 zy}P~B*4@3KL(hh8TYI~{4I7d+xOO{sc>7(>gJufPXY&l1!92U$KIrf|+y~90VmKc4 zhkO%aG#Ci_!%(~?JT~eNiM}!aD4;i6ZJMw#e<&vUM1L$E43DQLlmo+&X#6k@m8&@+ z#-mV6n+}ieHkpTge?PRXgJ$fod%Qqf@^mIMbh_N$zALSc{a%ODBh`|03TRk!^JFyg z#vZ8AsL<^1QCd}KK9u?x^2dGgSWlbK9EyZ}ZREpo`7|w`j`Y}s=BaQHB25U*VSiv8 z@Wgtyg{MLxq50U6xOhAc{t;h59E$`4r^y%vkGEV!>O&!ps zhrNTsHIc9=*fuyxp9-33f7XkU>dR7SvMWm{e0I`uqyDgOr$0V5LHc%wx^MToJp%Z( z>GbY)Tbo`ZbhX*K^?TOF_Ki}=lJ@ST4XNJkjrzl}qhb^~BkkzgP7ci-j86qbUu4V| zi3Z1m8QnZ{7_@YEYdX7jAhfl1cXw~pL`WMAoo(c$6Jzm*L}q6MXtoW|sIO-@xV)Cu zO$>*j;|9ZGKo}Pz<5B3nqqoNR- zj>W}^gRjsRw}q)@>yD&K+2N1I#TZ;)Xd$m8Gf#dHQjz8;WOnsO$I6;vJe>$8YL&}B z)FB|ogQH|DE&t8`l`Fq=JseQFw%Mv&59Q0`KqNdab$PQ@xq4>IV#+)L7eG1YdOAvk z6#bYG3=08qG&tc8!I{iYF(DEb;)g{+VIP}3EJh>2KqY@A!&Q11{?1AdUt0Cus(4jR z<>zut_!x`?kN^@u0!RP}AOR$R1dsp{Kmvq-zo@*SwxP1e$Z=dzC=&M_iojyoADtc( z;k%xw7*qSTx3zCHn(+e?zFXbwrx7_iuWVGw|l<1LVyhI)%UbtMb3Lq z+sT<43fZ%VsrBTWh-A)Vl1rU++vd)$&9<)AuFkFl_m6a6dq4c)`L^ER z_qIV+;GR+3j|<>l{+lkNY%@5RA}O;Z5|VRT#x`PeTUYmipxI+6udS`E>@gUC=%oLs zI04_V(zMjAO}?RPbNj~D?u~5+ijVkQO#?vCb{mun6+H0k*uZ zwoc+@C^|G148a#U<4I&m53P0W8#lLiw03QIy2_OL^fCP{L5A%+Voc6c>;<6I@<9}IMv0-hQ{OfPxr_dC5o^SWYgZS6XegCu51 zLkIeu4v*K}?}WV$YK@RyDuhI#F_PH}2Ch~LrVsIF#JISKYmj27pX^VI#jjY$a&=Oy z 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 + + + + + Yard Map + + + + +
+ +
+
+
+
+ + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..6f987e6 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "yardmap", + "private": true, + "type": "module" +} \ No newline at end of file diff --git a/src/data/anchors.js b/src/data/anchors.js new file mode 100644 index 0000000..f7a197b --- /dev/null +++ b/src/data/anchors.js @@ -0,0 +1,7 @@ +import { anchor } from "../lib/geometry.js"; +import { lotAssumedNorthClockwiseDegrees } from "./settings.js"; + +export const topLeftLotCorner = anchor(-100.89895832874616, 46.82679973173392, { + assumedNorthClockwiseDegrees: lotAssumedNorthClockwiseDegrees, +}); +export const lotCorner = topLeftLotCorner; \ No newline at end of file diff --git a/src/data/boundaries.js b/src/data/boundaries.js new file mode 100644 index 0000000..ae61595 --- /dev/null +++ b/src/data/boundaries.js @@ -0,0 +1,19 @@ +import { Fence, LotBoundary } from "../lib/yard-features.js"; + +const lotBoundary = new LotBoundary().trace([ + [-100.89895832874616, 46.82679973173392], + [-100.89839320971627, 46.8268766687031], + [-100.89835104203355, 46.82673956300183], + [-100.89891868391628, 46.82666533836659], +]); + +const picketFence = new Fence("Short picket fence", { + fenceType: "Short picket", +}).trace([ + [-100.89860584453643, 46.82671142438681], + [-100.89891903869233, 46.82666876370629], + [-100.89895733199393, 46.82679939652137], + [-100.89883731628171, 46.82681591825169], +]); + +export const boundaryFeatures = [lotBoundary, picketFence]; \ No newline at end of file diff --git a/src/data/buildings.js b/src/data/buildings.js new file mode 100644 index 0000000..9a7b69d --- /dev/null +++ b/src/data/buildings.js @@ -0,0 +1,42 @@ +import { Deck, defineGroup, Garage, House, Porch } from "../lib/yard-features.js"; + +export const housePartsGroup = defineGroup("group:house-parts", "House Parts"); + +const house = new House().withId("feature:house-main").trace([ + [-100.89883475400929, 46.826808023423844], + [-100.8986668040935, 46.826830709973976], + [-100.89866031675769, 46.826807530237865], + [-100.89869131180652, 46.82680358474988], + [-100.89867545387456, 46.82675525249843], + [-100.89881168792644, 46.82673552503637], +]); + +const garage = new Garage().trace([ + [-100.89850173743805, 46.82679569377302], + [-100.89839289435957, 46.82681098253964], + [-100.89837054909181, 46.82674095008918], + [-100.8984801129854, 46.826727140862815], +]); + +const porch = new Porch().childOf(house).inGroup(housePartsGroup).trace([ + [-100.89886286579778, 46.8268040779359], + [-100.8988346639074, 46.8268079617756], + [-100.8988116428755, 46.8267355250364], + [-100.89875969913811, 46.826743015307995], + [-100.89875316675139, 46.826722208995406], + [-100.8988050654378, 46.82671481119343], + [-100.89881515684904, 46.8267162907539], + [-100.89882614927917, 46.8267201129516], + [-100.89883678130174, 46.82672738745619], + [-100.89884281812813, 46.82673191860895], +]); + +const deck = new Deck().childOf(house).inGroup(housePartsGroup).trace([ + [-100.89869090634811, 46.826803553925714], + [-100.89864495438617, 46.826809472157606], + [-100.89863017767685, 46.8267616330979], + [-100.89867522861992, 46.82675522167426], +]); + +export const buildingFeatures = [house, garage, porch, deck]; +export const buildingGroups = [housePartsGroup]; \ No newline at end of file diff --git a/src/data/irrigation.js b/src/data/irrigation.js new file mode 100644 index 0000000..4c3e0d5 --- /dev/null +++ b/src/data/irrigation.js @@ -0,0 +1,8 @@ +import { east, ft, offset, south } from "../lib/geometry.js"; +import { Sprinkler } from "../lib/yard-features.js"; +import { lotCorner } from "./anchors.js"; + +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 diff --git a/src/data/pavement.js b/src/data/pavement.js new file mode 100644 index 0000000..4008cdd --- /dev/null +++ b/src/data/pavement.js @@ -0,0 +1,51 @@ +import { Driveway, Walkway } from "../lib/yard-features.js"; + +const frontWalk = new Walkway("Front walk").trace([ + [-100.89894913835376, 46.82676927748655], + [-100.89887345276941, 46.82677975769509], + [-100.89887849847504, 46.82679985503042], + [-100.89887651623354, 46.82680417040822], + [-100.8988709299166, 46.826808732378645], + [-100.89886462278457, 46.82681082841896], + [-100.89883254651308, 46.82681514379587], + [-100.89883047416976, 46.826808562845976], + [-100.89886426237707, 46.82680492559929], + [-100.89886660502609, 46.826802891207], + [-100.89886716816289, 46.82679990126662], + [-100.89886270811955, 46.82678063618305], + [-100.89885982485919, 46.82678254727965], + [-100.89885694159882, 46.82678341035552], + [-100.89885243650448, 46.826766457077134], + [-100.89885784261767, 46.82676596389077], + [-100.89886288832331, 46.82676682696687], + [-100.89886694290817, 46.82676836817423], + [-100.8989459622624, 46.826758196205], +]); + +const driveway = new Driveway().trace([ + [-100.89851482473702, 46.82685818966149], + [-100.8984941013032, 46.826796541436565], + [-100.89837336477572, 46.82681355635369], + [-100.89839320971627, 46.8268766687031], +]); + +const garageWalk = new Walkway("Garage walk").trace([ + [-100.89862628077022, 46.82677780036217], + [-100.8986050167251, 46.82677755376906], + [-100.89853023215954, 46.82678544474858], + [-100.89851239198609, 46.82679937725647], + [-100.89850049853712, 46.82681602228328], + [-100.8984941013032, 46.826796541436565], + [-100.89850173743805, 46.82679569377302], + [-100.89849635385036, 46.82677854014156], + [-100.89862267669479, 46.82676559400101], +]); + +const stepPad = new Walkway("Step pad").trace([ + [-100.89837579752665, 46.82675708653554], + [-100.89835705633432, 46.826759675764286], + [-100.89835309185132, 46.82674722280572], + [-100.89837111222856, 46.82674500346631], +]); + +export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad]; \ No newline at end of file diff --git a/src/data/plants.js b/src/data/plants.js new file mode 100644 index 0000000..d7fa3df --- /dev/null +++ b/src/data/plants.js @@ -0,0 +1,106 @@ +import { defineGroup, FlowerBed, Tree } from "../lib/yard-features.js"; +import { east, ft, lngLat, offset } from "../lib/geometry.js"; +import { topLeftLotCorner } from "./anchors.js"; + +export const juniperTreesGroup = defineGroup("group:juniper-trees", "Juniper Trees"); + +const flowerBeds = [ + new FlowerBed("Herbs").trace([ + [-100.89863446807426, 46.82677625460535], + [-100.89862628077022, 46.82677780036216], + [-100.89863677743469, 46.82681615158733], + [-100.89864678466313, 46.826814966528865], + ]), + new FlowerBed("Garden").trace([ + [-100.89865910125202, 46.82684024777001], + [-100.89865409763779, 46.82682773882406], + [-100.89853785983037, 46.82684209119338], + [-100.89854344078469, 46.82685618021274], + ]), + new FlowerBed("Back day lily").trace([ + [-100.89854344078469, 46.82685618021274], + [-100.89852458100802, 46.82685815530862], + [-100.89850764569832, 46.82680482769446], + [-100.89851239198609, 46.82679937725647], + [-100.89851803782018, 46.82680087749872], + [-100.89852554324152, 46.826804564348095], + ]), + new FlowerBed("Coneflowers").trace([ + [-100.89852458100802, 46.82685815530862], + [-100.89851482473702, 46.82685818966149], + [-100.89850049853712, 46.82681602228328], + [-100.89850764569832, 46.82680482769446], + ]), + new FlowerBed("Pathway border").trace([ + [-100.89881515684904, 46.82671629075389], + [-100.89881830278543, 46.82671252471132], + [-100.89882138193263, 46.82670673108083], + [-100.89881445385141, 46.8266828981853], + [-100.89877269291729, 46.82668908683872], + [-100.89877211557717, 46.8266923786754], + [-100.89876884398328, 46.82669501214458], + [-100.89876326302891, 46.82669764561363], + [-100.89875979898829, 46.82670304422476], + [-100.89875979898829, 46.826708574508785], + [-100.89876461015585, 46.826720556788906], + [-100.8988050654378, 46.82671481119343], + ]), +]; + +const trees = [ + new Tree("Japanese Empress Elm", { + species: "Japanese Empress Elm", + canopyDiameterMeters: 6, + heightMeters: 7, + }).add(lngLat(-100.89889018162828, 46.82675624026659)), + new Tree("Boulevard Linden", { + species: "Boulevard Linden", + canopyDiameterMeters: 3, + heightMeters: 6, + plantedDate: "2021-06-08", + }).add(lngLat(-100.8990003556445, 46.826776648309774)), + new Tree("Boulevard Linden 2", { + species: "Boulevard Linden", + canopyDiameterMeters: 3, + heightMeters: 6, + }).add(lngLat(-100.89898269567485, 46.826709328342595)), + new Tree("Evergreen", { + species: "Evergreen", + canopyDiameterMeters: 8, + heightMeters: 15, + }).add(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)), + new Tree("Medora Juniper 2", { + species: "Medora Juniper", + canopyDiameterMeters: 1.5, + heightMeters: 2.5, + }).inGroup(juniperTreesGroup).add(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)), + new Tree("Medora Juniper 4", { + species: "Medora Juniper", + canopyDiameterMeters: 1.5, + heightMeters: 2.5, + }).inGroup(juniperTreesGroup).add(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)), + 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))), +]; + +export const plantFeatures = [...flowerBeds, ...trees]; +export const plantGroups = [juniperTreesGroup]; \ No newline at end of file diff --git a/src/data/settings.js b/src/data/settings.js new file mode 100644 index 0000000..0700634 --- /dev/null +++ b/src/data/settings.js @@ -0,0 +1 @@ +export const lotAssumedNorthClockwiseDegrees = -11.25369719051117; \ No newline at end of file diff --git a/src/data/yard.js b/src/data/yard.js new file mode 100644 index 0000000..88f411b --- /dev/null +++ b/src/data/yard.js @@ -0,0 +1,20 @@ +import { collectYardData } from "../lib/yard-features.js"; +import { boundaryFeatures } from "./boundaries.js"; +import { buildingFeatures, buildingGroups } from "./buildings.js"; +import { irrigationFeatures } from "./irrigation.js"; +import { pavementFeatures } from "./pavement.js"; +import { plantFeatures, plantGroups } from "./plants.js"; + +export const yardGeoJSON = collectYardData( + [ + ...boundaryFeatures, + ...buildingFeatures, + ...pavementFeatures, + ...plantFeatures, + ...irrigationFeatures, + ], + [ + ...buildingGroups, + ...plantGroups, + ], +); \ No newline at end of file diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js new file mode 100644 index 0000000..1bbb191 --- /dev/null +++ b/src/lib/feature-types.js @@ -0,0 +1,125 @@ +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", + style: { + color: "#7b684d", + weight: 2, + dashArray: "8 5", + fillColor: "#dccaab", + fillOpacity: 0.08, + }, + }, + fence: { + label: "Fence", + style: { + color: "#705033", + weight: 4, + opacity: 0.95, + }, + }, + house: { + label: "House", + style: { + color: "#8d5a35", + weight: 2, + fillColor: "#c98d58", + fillOpacity: 0.35, + }, + }, + garage: { + label: "Garage", + style: { + color: "#60636b", + weight: 2, + fillColor: "#a4acb9", + fillOpacity: 0.45, + }, + }, + porch: { + label: "Porch", + style: { + color: "#aa7148", + weight: 2, + fillColor: "#deb588", + fillOpacity: 0.38, + }, + }, + deck: { + label: "Deck", + style: { + color: "#7f5a3c", + weight: 2, + fillColor: "#b98c66", + fillOpacity: 0.32, + }, + }, + walkway: { + label: "Walkway", + style: { + color: "#b8b3a6", + weight: 2, + fillColor: "#d6d1c3", + fillOpacity: 0.75, + }, + }, + driveway: { + label: "Driveway", + style: { + color: "#9c9890", + weight: 2, + fillColor: "#bbb7af", + fillOpacity: 0.8, + }, + }, + flowerBed: { + label: "Flower Bed", + style: { + color: "#7f7f31", + weight: 2, + fillColor: "#bfc97b", + fillOpacity: 0.38, + }, + }, + tree: { + label: "Tree", + style: (details) => ({ + color: "#2f6b3d", + weight: 2, + radius: clamp(4 + (details.canopyDiameterMeters ?? 2), treeRadiusRange.min, treeRadiusRange.max), + fillColor: "#5c9e64", + fillOpacity: 0.85, + }), + }, + sprinkler: { + label: "Sprinkler", + style: { + color: "#2d7dd2", + radius: 5, + fillColor: "#88bdf2", + fillOpacity: 0.95, + }, + }, +}; + +export function getFeatureType(kind) { + const definition = featureTypes[kind]; + if (!definition) { + throw new Error(`Unknown feature type: ${kind}`); + } + + return definition; +} + +export function resolveFeatureStyle(kind, details = {}) { + const { style } = getFeatureType(kind); + return typeof style === "function" ? style(details) : style; +} \ No newline at end of file diff --git a/src/lib/geometry.js b/src/lib/geometry.js new file mode 100644 index 0000000..854b01e --- /dev/null +++ b/src/lib/geometry.js @@ -0,0 +1,204 @@ +const FEET_PER_METER = 3.280839895; +const METERS_PER_FOOT = 1 / FEET_PER_METER; +const EARTH_METERS_PER_DEGREE_LAT = 111320; + +function toRadians(value) { + return (value * Math.PI) / 180; +} + +function rotateLocalToTrueNorthEast(northMeters, eastMeters, assumedNorthClockwiseDegrees = 0) { + const clockwiseRadians = toRadians(assumedNorthClockwiseDegrees); + const trueNorthMeters = + northMeters * Math.cos(clockwiseRadians) - eastMeters * Math.sin(clockwiseRadians); + const trueEastMeters = + northMeters * Math.sin(clockwiseRadians) + eastMeters * Math.cos(clockwiseRadians); + + return { + northMeters: trueNorthMeters, + eastMeters: trueEastMeters, + }; +} + +export function point(lat, lon) { + return { lat, lon }; +} + +export function lngLat(lon, lat) { + return point(lat, lon); +} + +export function anchor(lon, lat, options = {}) { + return { + ...lngLat(lon, lat), + ...options, + }; +} + +export function pointsFromLngLat(coordinates) { + return coordinates.map(([lon, lat]) => lngLat(lon, lat)); +} + +export function ft(value) { + return value * METERS_PER_FOOT; +} + +function isPoint(value) { + return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number"; +} + +function isMove(value) { + return ( + Boolean(value) && + typeof value.northMeters === "number" && + typeof value.eastMeters === "number" + ); +} + +function isOffsetAnchor(value) { + return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin); +} + +function translate(origin, northMeters, eastMeters) { + const latDelta = northMeters / EARTH_METERS_PER_DEGREE_LAT; + const lonDelta = eastMeters / (EARTH_METERS_PER_DEGREE_LAT * Math.cos(toRadians(origin.lat))); + + return point(origin.lat + latDelta, origin.lon + lonDelta); +} + +export function offset(origin, ...moves) { + if (moves.length === 0) { + return { kind: "offset-anchor", origin }; + } + + const net = moves.reduce( + (accumulator, move) => ({ + northMeters: accumulator.northMeters + move.northMeters, + eastMeters: accumulator.eastMeters + move.eastMeters, + }), + { northMeters: 0, eastMeters: 0 }, + ); + + const adjusted = rotateLocalToTrueNorthEast( + net.northMeters, + net.eastMeters, + origin.assumedNorthClockwiseDegrees ?? 0, + ); + + return translate(origin, adjusted.northMeters, adjusted.eastMeters); +} + +export function north(distanceMeters) { + return { northMeters: distanceMeters, eastMeters: 0 }; +} + +export function south(distanceMeters) { + return { northMeters: -distanceMeters, eastMeters: 0 }; +} + +export function east(distanceMeters) { + return { northMeters: 0, eastMeters: distanceMeters }; +} + +export function west(distanceMeters) { + return { northMeters: 0, eastMeters: -distanceMeters }; +} + +function buildFeature(type, geometryType, style = {}) { + const coordinates = []; + + return { + add(...values) { + coordinates.push(resolveCoordinate(values)); + return this; + }, + toGeoJSON() { + if (geometryType === "Point") { + const [first] = coordinates; + return { + type: "Feature", + properties: { type, style }, + geometry: { + type: "Point", + coordinates: [first.lon, first.lat], + }, + }; + } + + const ring = coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]); + const geometry = + geometryType === "LineString" + ? { type: "LineString", coordinates: ring } + : { type: "Polygon", coordinates: [closeRing(ring)] }; + + return { + type: "Feature", + properties: { type, style }, + geometry, + }; + }, + }; +} + +function resolveCoordinate(values) { + if (values.length === 1 && isPoint(values[0])) { + return values[0]; + } + + const [first, ...rest] = values; + if (isOffsetAnchor(first) && rest.every(isMove)) { + return offset(first.origin, ...rest); + } + + throw new Error("add(...) expects a point, or offset(origin) followed by directional moves"); +} + +function closeRing(coordinates) { + if (coordinates.length === 0) { + return coordinates; + } + + const [firstLon, firstLat] = coordinates[0]; + const [lastLon, lastLat] = coordinates[coordinates.length - 1]; + if (firstLon === lastLon && firstLat === lastLat) { + return coordinates; + } + + return [...coordinates, coordinates[0]]; +} + +export function polygon(type, style) { + return buildFeature(type, "Polygon", style); +} + +export function path(type, style) { + return buildFeature(type, "LineString", style); +} + +export function marker(type, style) { + return buildFeature(type, "Point", style); +} + +export function sprinkler(name) { + return marker(name, { + color: "#2d7dd2", + radius: 5, + fillColor: "#88bdf2", + fillOpacity: 0.95, + }); +} + +export function tree(name) { + return marker(name, { + color: "#2f6b3d", + radius: 9, + fillColor: "#5c9e64", + fillOpacity: 0.85, + }); +} + +export function collectGeoJSON(...collections) { + return { + type: "FeatureCollection", + features: collections.flat().map((entry) => entry.toGeoJSON()), + }; +} \ No newline at end of file diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js new file mode 100644 index 0000000..85ec4a7 --- /dev/null +++ b/src/lib/yard-features.js @@ -0,0 +1,279 @@ +import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js"; +import { getFeatureType, resolveFeatureStyle } from "./feature-types.js"; + +class YardFeature { + constructor(kind, geometryType, name, details = {}) { + this.kind = kind; + this.geometryType = geometryType; + this.name = name ?? getFeatureType(kind).label; + this.id = buildFeatureId(kind, this.name); + this.details = { ...details }; + this.coordinates = []; + this.parentId = null; + this.groupIds = []; + } + + add(...values) { + this.coordinates.push(resolveCoordinate(values)); + return this; + } + + trace(coordinates) { + for (const coordinate of pointsFromLngLat(coordinates)) { + this.add(coordinate); + } + + return this; + } + + withDetails(details) { + Object.assign(this.details, details); + return this; + } + + withId(id) { + this.id = id; + return this; + } + + childOf(parent) { + this.parentId = resolveFeatureReference(parent); + return this; + } + + inGroup(...groups) { + this.groupIds.push(...groups.map(resolveGroupReference)); + this.groupIds = [...new Set(this.groupIds)]; + return this; + } + + toGeoJSON() { + const label = getFeatureType(this.kind).label; + const style = resolveFeatureStyle(this.kind, this.details); + + if (this.geometryType === "Point") { + const [first] = this.coordinates; + return { + type: "Feature", + properties: { + id: this.id, + kind: this.kind, + label, + name: this.name, + parentId: this.parentId, + groupIds: this.groupIds, + details: this.details, + style, + }, + geometry: { + type: "Point", + coordinates: [first.lon, first.lat], + }, + }; + } + + const coordinates = this.coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]); + const geometry = + this.geometryType === "LineString" + ? { type: "LineString", coordinates } + : { type: "Polygon", coordinates: [closeRing(coordinates)] }; + + return { + type: "Feature", + properties: { + id: this.id, + kind: this.kind, + label, + name: this.name, + parentId: this.parentId, + groupIds: this.groupIds, + details: this.details, + style, + }, + geometry, + }; + } +} + +function buildFeatureId(kind, name) { + return `${kind}:${slugify(name)}`; +} + +function slugify(value) { + return String(value) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-+|-+$/g, "") + .replaceAll(/-{2,}/g, "-"); +} + +function resolveFeatureReference(featureOrId) { + if (typeof featureOrId === "string") { + return featureOrId; + } + + if (featureOrId && typeof featureOrId.id === "string") { + return featureOrId.id; + } + + throw new Error("Expected a feature or feature id"); +} + +function resolveGroupReference(groupOrId) { + if (typeof groupOrId === "string") { + return groupOrId; + } + + if (groupOrId && typeof groupOrId.id === "string") { + return groupOrId.id; + } + + throw new Error("Expected a group or group id"); +} + +export function defineGroup(id, label, options = {}) { + return { + id, + label, + parentGroupId: options.parentGroupId ?? null, + }; +} + +class PolygonFeature extends YardFeature { + constructor(kind, name, details) { + super(kind, "Polygon", name, details); + } +} + +class LineFeature extends YardFeature { + constructor(kind, name, details) { + super(kind, "LineString", name, details); + } +} + +class PointFeature extends YardFeature { + constructor(kind, name, details) { + super(kind, "Point", name, details); + } +} + +function closeRing(coordinates) { + if (coordinates.length === 0) { + return coordinates; + } + + const [firstLon, firstLat] = coordinates[0]; + const [lastLon, lastLat] = coordinates[coordinates.length - 1]; + if (firstLon === lastLon && firstLat === lastLat) { + return coordinates; + } + + return [...coordinates, coordinates[0]]; +} + +function isPoint(value) { + return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number"; +} + +function isMove(value) { + return ( + Boolean(value) && + typeof value.northMeters === "number" && + typeof value.eastMeters === "number" + ); +} + +function isOffsetAnchor(value) { + return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin); +} + +function resolveCoordinate(values) { + if (values.length === 1 && isPoint(values[0])) { + return values[0]; + } + + const [first, ...rest] = values; + if (isOffsetAnchor(first) && rest.every(isMove)) { + return offset(first.origin, ...rest); + } + + throw new Error("add(...) expects a point, or offset(origin) followed by directional moves"); +} + +export class LotBoundary extends PolygonFeature { + constructor(name = "Lot Boundary", details = {}) { + super("lotBoundary", name, details); + } +} + +export class Fence extends LineFeature { + constructor(name = "Fence", details = {}) { + super("fence", name, details); + } +} + +export class House extends PolygonFeature { + constructor(name = "House", details = {}) { + super("house", name, details); + } +} + +export class Garage extends PolygonFeature { + constructor(name = "Garage", details = {}) { + super("garage", name, details); + } +} + +export class Porch extends PolygonFeature { + constructor(name = "Porch", details = {}) { + super("porch", name, details); + } +} + +export class Deck extends PolygonFeature { + constructor(name = "Deck", details = {}) { + super("deck", name, details); + } +} + +export class Walkway extends PolygonFeature { + constructor(name = "Walkway", details = {}) { + super("walkway", name, details); + } +} + +export class Driveway extends PolygonFeature { + constructor(name = "Driveway", details = {}) { + super("driveway", name, details); + } +} + +export class FlowerBed extends PolygonFeature { + constructor(name = "Flower Bed", details = {}) { + super("flowerBed", name, details); + } +} + +export class Tree extends PointFeature { + constructor(name = "Tree", details = {}) { + super("tree", name, details); + } +} + +export class Sprinkler extends PointFeature { + constructor(name = "Sprinkler", details = {}) { + super("sprinkler", name, details); + } +} + +export function collectYardData(features, groups = []) { + const collection = collectGeoJSON(features); + collection.groups = groups.map((group) => ({ + id: group.id, + label: group.label, + parentGroupId: group.parentGroupId ?? null, + })); + return collection; +} + +export { collectGeoJSON }; \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..d558fe7 --- /dev/null +++ b/src/main.js @@ -0,0 +1,139 @@ +import { yardGeoJSON } from "./data/yard.js"; + +const TILE_MAX_NATIVE_ZOOM = 19; +const MAP_MAX_ZOOM = 24; + +const map = L.map("map", { + zoomControl: false, + maxZoom: MAP_MAX_ZOOM, +}); + +L.control + .zoom({ + position: "bottomright", + }) + .addTo(map); + +L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxNativeZoom: TILE_MAX_NATIVE_ZOOM, + maxZoom: MAP_MAX_ZOOM, + attribution: '© OpenStreetMap', +}).addTo(map); + +const geoJsonLayer = L.geoJSON(yardGeoJSON, { + pointToLayer(feature, latlng) { + const style = feature.properties.style; + return L.circleMarker(latlng, { + radius: style.radius ?? 6, + color: style.color ?? "#333333", + fillColor: style.fillColor ?? style.color ?? "#333333", + fillOpacity: style.fillOpacity ?? 0.8, + weight: style.weight ?? 2, + }); + }, + style(feature) { + return feature.properties.style; + }, + onEachFeature(feature, layer) { + layer.bindPopup(buildPopupContent(feature.properties)); + }, +}).addTo(map); + +map.fitBounds(geoJsonLayer.getBounds().pad(0.2), { maxZoom: MAP_MAX_ZOOM }); + +const legend = document.getElementById("legend"); +const legendEntries = summarizeTypes(yardGeoJSON.features); + +legend.replaceChildren( + ...legendEntries.map((entry) => { + const row = document.createElement("div"); + row.className = "legend-item"; + + const swatch = document.createElement("span"); + swatch.className = "legend-swatch"; + swatch.style.background = entry.color; + + const label = document.createElement("span"); + label.textContent = `${entry.label} (${entry.count})`; + + row.append(swatch, label); + return row; + }), +); + +function summarizeTypes(features) { + const summary = new Map(); + + for (const feature of features) { + const key = feature.properties.kind; + const existing = summary.get(key) ?? { + label: feature.properties.label, + count: 0, + color: feature.properties.style.color ?? "#333333", + }; + + existing.count += 1; + summary.set(key, existing); + } + + return [...summary.values()]; +} + +function buildPopupContent(properties) { + const { label, name, details, parentId, groupIds } = properties; + const entries = Object.entries(details ?? {}).filter(([, value]) => value !== null && value !== undefined); + const relationshipEntries = []; + + if (parentId) { + relationshipEntries.push(["parentFeature", parentId]); + } + + if (groupIds?.length) { + relationshipEntries.push(["groups", groupIds.join(", ")]); + } + + const title = `${escapeHtml(name)}`; + const subtitle = name === label ? "" : `
${escapeHtml(label)}
`; + const allEntries = [...entries, ...relationshipEntries]; + if (allEntries.length === 0) { + return `${title}${subtitle}`; + } + + const detailRows = allEntries + .map(([key, value]) => `
${escapeHtml(formatDetailLabel(key))}: ${escapeHtml(formatDetailValue(key, value))}
`) + .join(""); + + return `${title}${subtitle}
${detailRows}
`; +} + +function formatDetailLabel(key) { + const labels = { + canopyDiameterMeters: "Canopy diameter", + heightMeters: "Height", + plantedDate: "Planted", + species: "Species", + fenceType: "Fence type", + note: "Note", + parentFeature: "Parent", + groups: "Groups", + }; + + return labels[key] ?? key; +} + +function formatDetailValue(key, value) { + if (key === "canopyDiameterMeters" || key === "heightMeters") { + return `${value} m`; + } + + return String(value); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f55f3ac --- /dev/null +++ b/styles.css @@ -0,0 +1,136 @@ +:root { + color-scheme: light; + --bg: #f3efe2; + --panel: rgba(253, 250, 240, 0.88); + --panel-border: rgba(78, 62, 36, 0.15); + --ink: #2e2619; + --muted: #64563f; + --accent: #466b45; + --accent-soft: #cedbbf; + --shadow: 0 24px 80px rgba(60, 44, 22, 0.18); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.72), transparent 30%), + linear-gradient(135deg, #e7e0c7 0%, #f7f3e8 50%, #e4ead9 100%); +} + +body { + min-height: 100vh; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + min-height: 100vh; + gap: 1rem; + padding: 1rem; +} + +.panel { + padding: 1.5rem; + border: 1px solid var(--panel-border); + border-radius: 24px; + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.72rem; + color: var(--accent); +} + +h1 { + margin: 0.4rem 0 1rem; + font-size: clamp(2.2rem, 4vw, 3.6rem); + line-height: 0.95; +} + +.intro, +.notes { + color: var(--muted); + line-height: 1.5; +} + +.legend { + display: grid; + gap: 0.65rem; + margin: 1.5rem 0; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.95rem; +} + +.legend-swatch { + width: 0.9rem; + height: 0.9rem; + border-radius: 999px; + flex: none; + border: 1px solid rgba(0, 0, 0, 0.12); +} + +.notes h2 { + margin-bottom: 0.6rem; + color: var(--ink); + font-size: 1rem; +} + +pre { + margin: 0; + padding: 0.9rem; + overflow-x: auto; + border-radius: 16px; + background: rgba(70, 107, 69, 0.08); +} + +code { + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.86rem; +} + +main { + min-width: 0; +} + +#map { + min-height: calc(100vh - 2rem); + border-radius: 28px; + overflow: hidden; + box-shadow: var(--shadow); +} + +.leaflet-popup-content-wrapper { + border-radius: 14px; +} + +.leaflet-popup-content { + font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + #map { + min-height: 65vh; + } +} \ No newline at end of file